doco-cli 0.1.0

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.
@@ -0,0 +1,362 @@
1
+ -- Doco Postgres schema (decision_01KRKEVEE3RQGPWHAPMZ0MS9G9).
2
+ --
3
+ -- Source-of-truth + read-side index in one database per host. Replaces
4
+ -- the filesystem-and-git shape that ADR-023 / ADR-024 described.
5
+ --
6
+ -- Single-database, multi-tenant: every entity carries its `doco_id`
7
+ -- which scopes it to the owning Doco. Hosts can hold thousands of
8
+ -- Doco instances in one database.
9
+ --
10
+ -- Schema rules:
11
+ -- - Each entity type gets its own table; common columns live up top
12
+ -- in a consistent order (id, doco_id, summary, lifecycle, ...).
13
+ -- - Type-specific columns are appended.
14
+ -- - `body_md` is on the types that have a markdown narrative body.
15
+ -- - `edges` materializes cross-entity references for graph queries.
16
+ -- - `audit_events` is the structured history (decision_01KRKESCBTYG4005VMPKYNYR53).
17
+
18
+ -- Schema version. Tracked separately from app version so DB migrations
19
+ -- don't gate code releases. v1 = initial Phase 2 cut.
20
+ CREATE TABLE IF NOT EXISTS doco_meta (
21
+ key text PRIMARY KEY,
22
+ value text NOT NULL
23
+ );
24
+ INSERT INTO doco_meta (key, value) VALUES ('schema_version', '1') ON CONFLICT DO NOTHING;
25
+
26
+ -- Host config (singleton row at id='host'). Replaces <root>/host.yaml
27
+ -- (rule_01KRKQDHWNWJAF4YKTMCB2A0D9 — alpha forbids back-compat).
28
+ CREATE TABLE IF NOT EXISTS hosts (
29
+ id text PRIMARY KEY,
30
+ name text NOT NULL,
31
+ visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('public', 'private')),
32
+ raw_yaml text NOT NULL,
33
+ created_at timestamptz NOT NULL DEFAULT now(),
34
+ updated_at timestamptz NOT NULL DEFAULT now()
35
+ );
36
+
37
+ -- Identity layer.
38
+
39
+ CREATE TABLE IF NOT EXISTS principals (
40
+ id text PRIMARY KEY,
41
+ username text NOT NULL UNIQUE,
42
+ type text NOT NULL CHECK (type IN ('human', 'agent')),
43
+ display_name text,
44
+ email text,
45
+ github_login text,
46
+ avatar_url text,
47
+ owner_id text REFERENCES principals(id) ON DELETE SET NULL,
48
+ raw_yaml text NOT NULL,
49
+ created_at timestamptz NOT NULL DEFAULT now(),
50
+ updated_at timestamptz NOT NULL DEFAULT now(),
51
+ deactivated_at timestamptz
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS organizations (
55
+ id text PRIMARY KEY,
56
+ slug text NOT NULL UNIQUE,
57
+ name text NOT NULL,
58
+ raw_yaml text NOT NULL,
59
+ created_at timestamptz NOT NULL DEFAULT now(),
60
+ updated_at timestamptz NOT NULL DEFAULT now()
61
+ );
62
+
63
+ -- Organization membership (replaces members[] array inside organizations.yaml).
64
+ -- Must come after both `principals` and `organizations` — its FKs reference them.
65
+ CREATE TABLE IF NOT EXISTS org_members (
66
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
67
+ principal_id text NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
68
+ role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
69
+ joined_at timestamptz NOT NULL DEFAULT now(),
70
+ PRIMARY KEY (org_id, principal_id)
71
+ );
72
+ CREATE INDEX IF NOT EXISTS org_members_principal_idx ON org_members (principal_id, role);
73
+
74
+ CREATE TABLE IF NOT EXISTS docos (
75
+ id text PRIMARY KEY,
76
+ owner_slug text NOT NULL,
77
+ doco_slug text NOT NULL,
78
+ owner_id text NOT NULL, -- principal_<ulid> OR organization_<ulid>
79
+ name text,
80
+ visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('public', 'private')),
81
+ raw_yaml text NOT NULL,
82
+ created_at timestamptz NOT NULL DEFAULT now(),
83
+ updated_at timestamptz NOT NULL DEFAULT now(),
84
+ UNIQUE (owner_slug, doco_slug)
85
+ );
86
+
87
+ -- Per-Doco entity tables. `body_md` carries the markdown narrative
88
+ -- on types that have one; the rest of the structured data lives in
89
+ -- `raw_yaml` (round-trippable to/from the legacy file format).
90
+
91
+ CREATE TABLE IF NOT EXISTS intents (
92
+ id text PRIMARY KEY,
93
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
94
+ summary text,
95
+ lifecycle text,
96
+ body_md text,
97
+ raw_yaml text NOT NULL,
98
+ created_at timestamptz NOT NULL DEFAULT now(),
99
+ created_by text,
100
+ updated_at timestamptz NOT NULL DEFAULT now(),
101
+ updated_by text
102
+ );
103
+ CREATE INDEX IF NOT EXISTS intents_doco_idx ON intents (doco_id, created_at DESC);
104
+ CREATE INDEX IF NOT EXISTS intents_lifecycle_idx ON intents (doco_id, lifecycle);
105
+
106
+ CREATE TABLE IF NOT EXISTS decisions (
107
+ id text PRIMARY KEY,
108
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
109
+ summary text,
110
+ lifecycle text,
111
+ body_md text,
112
+ raw_yaml text NOT NULL,
113
+ created_at timestamptz NOT NULL DEFAULT now(),
114
+ created_by text,
115
+ updated_at timestamptz NOT NULL DEFAULT now(),
116
+ updated_by text
117
+ );
118
+ CREATE INDEX IF NOT EXISTS decisions_doco_idx ON decisions (doco_id, created_at DESC);
119
+ CREATE INDEX IF NOT EXISTS decisions_lifecycle_idx ON decisions (doco_id, lifecycle);
120
+
121
+ CREATE TABLE IF NOT EXISTS rules (
122
+ id text PRIMARY KEY,
123
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
124
+ summary text,
125
+ lifecycle text,
126
+ body_md text,
127
+ raw_yaml text NOT NULL,
128
+ created_at timestamptz NOT NULL DEFAULT now(),
129
+ created_by text,
130
+ updated_at timestamptz NOT NULL DEFAULT now(),
131
+ updated_by text
132
+ );
133
+ CREATE INDEX IF NOT EXISTS rules_doco_idx ON rules (doco_id, created_at DESC);
134
+ CREATE INDEX IF NOT EXISTS rules_lifecycle_idx ON rules (doco_id, lifecycle);
135
+
136
+ CREATE TABLE IF NOT EXISTS actions (
137
+ id text PRIMARY KEY,
138
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
139
+ summary text,
140
+ lifecycle text,
141
+ body_md text,
142
+ raw_yaml text NOT NULL,
143
+ created_at timestamptz NOT NULL DEFAULT now(),
144
+ created_by text,
145
+ updated_at timestamptz NOT NULL DEFAULT now(),
146
+ updated_by text
147
+ );
148
+ CREATE INDEX IF NOT EXISTS actions_doco_idx ON actions (doco_id, created_at DESC);
149
+ CREATE INDEX IF NOT EXISTS actions_lifecycle_idx ON actions (doco_id, lifecycle);
150
+
151
+ CREATE TABLE IF NOT EXISTS logs (
152
+ id text PRIMARY KEY,
153
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
154
+ summary text,
155
+ lifecycle text,
156
+ body_md text,
157
+ raw_yaml text NOT NULL,
158
+ created_at timestamptz NOT NULL DEFAULT now(),
159
+ created_by text,
160
+ updated_at timestamptz NOT NULL DEFAULT now(),
161
+ updated_by text
162
+ );
163
+ CREATE INDEX IF NOT EXISTS logs_doco_idx ON logs (doco_id, created_at DESC);
164
+ CREATE INDEX IF NOT EXISTS logs_lifecycle_idx ON logs (doco_id, lifecycle);
165
+
166
+ CREATE TABLE IF NOT EXISTS evals (
167
+ id text PRIMARY KEY,
168
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
169
+ summary text,
170
+ lifecycle text,
171
+ body_md text,
172
+ raw_yaml text NOT NULL,
173
+ created_at timestamptz NOT NULL DEFAULT now(),
174
+ created_by text,
175
+ updated_at timestamptz NOT NULL DEFAULT now(),
176
+ updated_by text
177
+ );
178
+ CREATE INDEX IF NOT EXISTS evals_doco_idx ON evals (doco_id, created_at DESC);
179
+ CREATE INDEX IF NOT EXISTS evals_lifecycle_idx ON evals (doco_id, lifecycle);
180
+
181
+ CREATE TABLE IF NOT EXISTS scopes (
182
+ id text PRIMARY KEY,
183
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
184
+ name text NOT NULL,
185
+ summary text,
186
+ lifecycle text,
187
+ raw_yaml text NOT NULL,
188
+ created_at timestamptz NOT NULL DEFAULT now(),
189
+ created_by text,
190
+ updated_at timestamptz NOT NULL DEFAULT now(),
191
+ updated_by text,
192
+ UNIQUE (doco_id, name)
193
+ );
194
+ CREATE INDEX IF NOT EXISTS scopes_doco_idx ON scopes (doco_id, created_at DESC);
195
+
196
+ CREATE TABLE IF NOT EXISTS tags (
197
+ id text PRIMARY KEY,
198
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
199
+ name text NOT NULL,
200
+ raw_yaml text NOT NULL,
201
+ created_at timestamptz NOT NULL DEFAULT now(),
202
+ UNIQUE (doco_id, name)
203
+ );
204
+
205
+ CREATE TABLE IF NOT EXISTS ideas (
206
+ id text PRIMARY KEY,
207
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
208
+ summary text,
209
+ lifecycle text,
210
+ body_md text,
211
+ raw_yaml text NOT NULL,
212
+ created_at timestamptz NOT NULL DEFAULT now(),
213
+ created_by text,
214
+ updated_at timestamptz NOT NULL DEFAULT now(),
215
+ updated_by text
216
+ );
217
+
218
+ CREATE TABLE IF NOT EXISTS reference_entities (
219
+ id text PRIMARY KEY,
220
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
221
+ summary text,
222
+ lifecycle text,
223
+ raw_yaml text NOT NULL,
224
+ created_at timestamptz NOT NULL DEFAULT now(),
225
+ created_by text,
226
+ updated_at timestamptz NOT NULL DEFAULT now(),
227
+ updated_by text
228
+ );
229
+
230
+ -- Doco-level Principal/Organization references (multi-tenant Principals
231
+ -- live in the host-level `principals` table above, but a Doco can record
232
+ -- which Principals are members for permissions). For Phase 2, we just
233
+ -- mirror the YAML files; full membership table comes later.
234
+
235
+ -- Audit events: structured replacement for git history
236
+ -- (decision_01KRKESCBTYG4005VMPKYNYR53). One row per mutation.
237
+
238
+ CREATE TABLE IF NOT EXISTS audit_events (
239
+ event_id text PRIMARY KEY,
240
+ at timestamptz NOT NULL,
241
+ by_principal text,
242
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
243
+ entity_type text NOT NULL,
244
+ entity_id text NOT NULL,
245
+ op text NOT NULL CHECK (op IN ('entity.create', 'entity.update', 'entity.delete', 'lifecycle.transition', 'edge.add')),
246
+ before_json jsonb,
247
+ after_json jsonb,
248
+ reason text
249
+ );
250
+ CREATE INDEX IF NOT EXISTS audit_events_entity_idx ON audit_events (entity_id, at DESC);
251
+ CREATE INDEX IF NOT EXISTS audit_events_doco_idx ON audit_events (doco_id, at DESC);
252
+ CREATE INDEX IF NOT EXISTS audit_events_op_idx ON audit_events (doco_id, op, at DESC);
253
+ CREATE INDEX IF NOT EXISTS audit_events_actor_idx ON audit_events (by_principal, at DESC);
254
+
255
+ -- Indexing layer tables. These hold the derived-data the read side
256
+ -- consumes — graph edges, vector embeddings, denormalized rule targets,
257
+ -- and full-text search rows. Supersedes ADR-023 (tiered architecture)
258
+ -- and ADR-024 (SQLite + FTS5) — Postgres is now both source of truth
259
+ -- and read-side index.
260
+
261
+ -- Graph edges (ADR-025). Materialized from frontmatter ID-shaped fields
262
+ -- by the indexer. attribution=='explicit' means declared in source;
263
+ -- 'doco-auto' means LLM-detected. Doco-scoped via doco_id; both
264
+ -- endpoints can be any node_type so we can't FK them.
265
+ CREATE TABLE IF NOT EXISTS edges (
266
+ from_id text NOT NULL,
267
+ from_node_type text NOT NULL,
268
+ to_id text NOT NULL,
269
+ to_node_type text NOT NULL,
270
+ edge_type text NOT NULL,
271
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
272
+ edge_props_json jsonb,
273
+ attribution text NOT NULL DEFAULT 'explicit' CHECK (attribution IN ('explicit', 'doco-auto')),
274
+ PRIMARY KEY (from_id, to_id, edge_type)
275
+ );
276
+ CREATE INDEX IF NOT EXISTS edges_doco_idx ON edges (doco_id);
277
+ CREATE INDEX IF NOT EXISTS edges_to_idx ON edges (to_id, edge_type);
278
+ CREATE INDEX IF NOT EXISTS edges_from_type_idx ON edges (from_id, edge_type);
279
+ CREATE INDEX IF NOT EXISTS edges_type_idx ON edges (edge_type);
280
+ CREATE INDEX IF NOT EXISTS edges_attribution_idx ON edges (attribution);
281
+
282
+ -- Vector embeddings (ADR-052). One row per entity. Storage is bytea
283
+ -- (Float32Array bytes, little-endian). pgvector + ivfflat/hnsw is an
284
+ -- additive optimization for Tier-C scale (currently Tier B per ADR-049,
285
+ -- where sequential cosine is microseconds). Switching to vector(N) later
286
+ -- is a column-type migration with no data reformat.
287
+ -- model_id + content_hash let the reindex hook skip work when nothing
288
+ -- changed; a model swap invalidates rows whose model_id differs.
289
+ CREATE TABLE IF NOT EXISTS embeddings (
290
+ entity_id text PRIMARY KEY,
291
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
292
+ model_id text NOT NULL,
293
+ content_hash text NOT NULL,
294
+ embedding bytea NOT NULL,
295
+ updated_at timestamptz NOT NULL DEFAULT now()
296
+ );
297
+ CREATE INDEX IF NOT EXISTS embeddings_doco_idx ON embeddings (doco_id);
298
+ CREATE INDEX IF NOT EXISTS embeddings_model_idx ON embeddings (model_id);
299
+
300
+ -- Denormalized Rule.applies_to → matched targets (ADR-026). Populated
301
+ -- by the indexer at write time. Lets runtime checks look up "which
302
+ -- Rules apply to this target?" in O(1) without re-evaluating selectors.
303
+ CREATE TABLE IF NOT EXISTS scope_match (
304
+ source_id text NOT NULL,
305
+ source_node_type text NOT NULL,
306
+ target_id text NOT NULL,
307
+ target_node_type text NOT NULL,
308
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
309
+ selector_rev integer NOT NULL DEFAULT 1,
310
+ PRIMARY KEY (source_id, target_id)
311
+ );
312
+ CREATE INDEX IF NOT EXISTS scope_match_target_idx ON scope_match (target_id);
313
+ CREATE INDEX IF NOT EXISTS scope_match_doco_idx ON scope_match (doco_id);
314
+
315
+ -- Full-text search. One row per
316
+ -- entity. The indexer populates summary + body; search_tsv is a
317
+ -- generated tsvector with English stemming and weighting (A=summary,
318
+ -- B=body). The GIN index handles `@@` queries efficiently.
319
+ CREATE TABLE IF NOT EXISTS entity_fts (
320
+ entity_id text PRIMARY KEY,
321
+ doco_id text NOT NULL REFERENCES docos(id) ON DELETE CASCADE,
322
+ node_type text NOT NULL,
323
+ summary text,
324
+ body text,
325
+ search_tsv tsvector GENERATED ALWAYS AS (
326
+ setweight(to_tsvector('english', coalesce(summary, '')), 'A') ||
327
+ setweight(to_tsvector('english', coalesce(body, '')), 'B')
328
+ ) STORED
329
+ );
330
+ CREATE INDEX IF NOT EXISTS entity_fts_doco_idx ON entity_fts (doco_id);
331
+ CREATE INDEX IF NOT EXISTS entity_fts_tsv_idx ON entity_fts USING gin (search_tsv);
332
+
333
+ -- Drop the legacy `revision` column from entity tables. Was incremented
334
+ -- on every upsert but never read by any TS code (decision_01KRHBZMD0V35NAX94Y7N2MXVA
335
+ -- flagged it; never fully removed). Idempotent — no-op on fresh DBs.
336
+ ALTER TABLE intents DROP COLUMN IF EXISTS revision;
337
+ ALTER TABLE decisions DROP COLUMN IF EXISTS revision;
338
+ ALTER TABLE rules DROP COLUMN IF EXISTS revision;
339
+ ALTER TABLE actions DROP COLUMN IF EXISTS revision;
340
+ ALTER TABLE evals DROP COLUMN IF EXISTS revision;
341
+ ALTER TABLE scopes DROP COLUMN IF EXISTS revision;
342
+ ALTER TABLE ideas DROP COLUMN IF EXISTS revision;
343
+ ALTER TABLE reference_entities DROP COLUMN IF EXISTS revision;
344
+
345
+ -- Deprecation (2026-05-16): the `reasoning` node type was removed.
346
+ -- Drop the legacy table and purge any dangling edges / embeddings /
347
+ -- audit rows so existing databases converge to the new shape on boot.
348
+ -- Idempotent — no-op on fresh DBs.
349
+ DROP TABLE IF EXISTS reasoning CASCADE;
350
+ DELETE FROM edges WHERE from_id LIKE 'reasoning\_%' ESCAPE '\' OR to_id LIKE 'reasoning\_%' ESCAPE '\';
351
+ DELETE FROM embeddings WHERE entity_id LIKE 'reasoning\_%' ESCAPE '\';
352
+ DELETE FROM audit_events WHERE entity_id LIKE 'reasoning\_%' ESCAPE '\';
353
+
354
+ -- ──────────────────────────────────────────────────────────────────────────
355
+ -- Token store (session tokens + CLI authorizations). Alpha keeps this as a
356
+ -- host-scoped JSON blob; move to one-row-per-token tables once the surface
357
+ -- stabilizes.
358
+ CREATE TABLE IF NOT EXISTS tokens_blob (
359
+ key text PRIMARY KEY,
360
+ blob jsonb NOT NULL,
361
+ updated_at timestamptz NOT NULL DEFAULT now()
362
+ );
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "doco-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Doco CLI — init, bootstrap, search, capture, patch, lint, query.",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/torrenegra/doco.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/torrenegra/doco#readme",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "bin": {
18
+ "doco": "./dist/index.js"
19
+ },
20
+ "files": ["dist", "templates"],
21
+ "scripts": {
22
+ "build": "tsx build.ts",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "vitest run --passWithNoTests",
25
+ "test:watch": "vitest",
26
+ "doco": "tsx src/index.ts",
27
+ "prepack": "pnpm build"
28
+ },
29
+ "dependencies": {
30
+ "citty": "^0.1.6",
31
+ "gray-matter": "^4.0.3",
32
+ "kleur": "^4.1.5",
33
+ "pg": "^8.13.1",
34
+ "yaml": "^2.6.1"
35
+ },
36
+ "devDependencies": {
37
+ "@doco/db": "workspace:*",
38
+ "@doco/host": "workspace:*",
39
+ "@doco/index": "workspace:*",
40
+ "@doco/lints": "workspace:*",
41
+ "@doco/shared": "workspace:*",
42
+ "esbuild": "^0.25.0"
43
+ }
44
+ }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook: fetch the Doco host's agent-bootstrap and inject
3
+ # `canonical_instructions` into the agent's context.
4
+ #
5
+ # Wired from `.claude/settings.json`. Output is the JSON envelope Claude
6
+ # Code's hook runner expects:
7
+ # { "hookSpecificOutput": { "hookEventName": "SessionStart",
8
+ # "additionalContext": "<text>" } }
9
+ #
10
+ # On failure (host unreachable, env missing, jq absent) we still emit a
11
+ # valid JSON envelope with a disconnected indicator so the agent never
12
+ # presents as connected without proper access.
13
+ #
14
+ # Per the `agent-md-stays-a-pointer` Rule + the
15
+ # `claude-md-becomes-a-strong-bootstrap-stub` Decision (this commit).
16
+
17
+ set -u
18
+
19
+ # Load .env if present. The production host is fixed at doco.to; env
20
+ # only carries the DOCO_TOKEN secret. DOCO_ID lives in AGENTS.md
21
+ # (committed, non-secret) — we read it from there if it's not already
22
+ # in env. Legacy repos with DOCO_ID in .env keep working: env wins
23
+ # over the AGENTS.md fallback.
24
+ if [ -f "$PWD/.env" ]; then
25
+ # shellcheck disable=SC1091
26
+ set -a
27
+ source "$PWD/.env"
28
+ set +a
29
+ fi
30
+ if [ -z "${DOCO_ID:-}" ]; then
31
+ for f in "$PWD/AGENTS.md" "$PWD/CLAUDE.md"; do
32
+ if [ -f "$f" ]; then
33
+ DOCO_ID=$(grep -oE 'doco_[A-Za-z0-9]+' "$f" | head -1)
34
+ [ -n "$DOCO_ID" ] && export DOCO_ID && break
35
+ fi
36
+ done
37
+ fi
38
+ DOCO_BASE_URL="https://doco.to"
39
+
40
+ emit_disconnected() {
41
+ # $1 indicator_reason — single-line "Not connected yet: <reason>" reason
42
+ # $2 mode — picks the recovery suffix (see below); defaults to "default"
43
+ # $3 body_extra — optional multi-line content inserted between
44
+ # the indicator and the recovery (e.g. the host's
45
+ # structured missing_doco_guidance actions)
46
+ #
47
+ # Recovery mode never bakes `doco login` into a sandbox-block case —
48
+ # the token is fine, the request just didn't leave the runtime.
49
+ # default — credentials issue, do `doco login` to re-authorize
50
+ # network — sandbox / network policy, allowlist doco.to in the agent runtime
51
+ # tool — missing local tool (curl / jq); install it
52
+ # verbatim — body_extra is authoritative; no generic suffix
53
+ local indicator_reason=$1
54
+ local mode=${2:-default}
55
+ local body_extra=${3:-}
56
+ local recovery
57
+ case "$mode" in
58
+ network)
59
+ recovery=$'**This is a sandbox / network-policy issue, not a credentials problem — `doco login` will NOT help.** The bearer token is fine; the bootstrap request didn\'t leave your agent runtime. Tell the project owner to allowlist `doco.to` in your agent\'s network settings (Codex web, GitHub Coding Agent, Anthropic web sandbox, etc. each have their own settings page). For a one-off, they may also approve the call inline in the agent\'s UI. Once the host is reachable, restart or `/clear` so SessionStart runs again.'
60
+ ;;
61
+ tool)
62
+ recovery=$'Install the missing local tool, then restart or `/clear` so SessionStart runs again.'
63
+ ;;
64
+ verbatim)
65
+ recovery=""
66
+ ;;
67
+ default|*)
68
+ recovery=$'Ask the project owner to authorize the agent with `doco login --host https://doco.to` (or `doco login --host https://doco.to --create <slug>` for a new Doco), then restart or `/clear` so SessionStart runs again.'
69
+ ;;
70
+ esac
71
+ local body
72
+ body=$'🔒 Doco connection — not connected\n\n⚠️ **Every reply must start with this exact line until access is fixed:**\n\n [🔮 Doco] Not connected yet: '"${indicator_reason}"$'\n\nDo not render regular Doco query/count/footer/tally lines while disconnected.'
73
+ if [ -n "$body_extra" ]; then
74
+ body="${body}"$'\n\n'"${body_extra}"
75
+ fi
76
+ if [ -n "$recovery" ]; then
77
+ body="${body}"$'\n\n'"${recovery}"
78
+ fi
79
+ if command -v jq >/dev/null 2>&1; then
80
+ jq -nc --arg c "$body" \
81
+ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
82
+ else
83
+ # jq missing — emit a literal valid JSON. Escape only what matters.
84
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"[🔮 Doco] Not connected yet: %s. jq is also missing — install it before retrying."}}\n' \
85
+ "$indicator_reason"
86
+ fi
87
+ }
88
+
89
+ if ! command -v curl >/dev/null 2>&1; then
90
+ emit_disconnected "curl is not installed" tool
91
+ exit 0
92
+ fi
93
+ if [ -z "${DOCO_ID:-}" ]; then
94
+ emit_disconnected "missing DOCO_ID — set the **This project's Doco ID** line at the top of AGENTS.md, or run \`doco login --host https://doco.to\` to stamp it" default
95
+ exit 0
96
+ fi
97
+ if [ -z "${DOCO_TOKEN:-}" ]; then
98
+ emit_disconnected "missing DOCO_TOKEN" default
99
+ exit 0
100
+ fi
101
+
102
+ if ! command -v jq >/dev/null 2>&1; then
103
+ emit_disconnected "jq is not installed" tool
104
+ exit 0
105
+ fi
106
+
107
+ DOCO_PARAM="?id=$(printf '%s' "$DOCO_ID" | jq -sRr @uri 2>/dev/null || printf '%s' "$DOCO_ID")"
108
+ TMP_RESP="${TMPDIR:-/tmp}/doco-bootstrap-$$.json"
109
+ HTTP_STATUS=$(curl -sS --max-time 8 -w '%{http_code}' -o "$TMP_RESP" \
110
+ -H "Authorization: Bearer ${DOCO_TOKEN}" \
111
+ "${DOCO_BASE_URL}/api/v1/agent-bootstrap${DOCO_PARAM}" 2>/dev/null || true)
112
+ RESP=$(cat "$TMP_RESP" 2>/dev/null || true)
113
+ rm -f "$TMP_RESP" 2>/dev/null || true
114
+ if [ -z "$RESP" ]; then
115
+ # Empty body OR curl exit non-zero means the request never reached
116
+ # the host (DNS, sandbox network gate, firewall). HTTP_STATUS:000 is
117
+ # the same case. Don't tell the project owner to `doco login` — the
118
+ # token is fine; the runtime is the blocker.
119
+ emit_disconnected "doco.to unreachable (HTTP_STATUS:000 / network blocked at the agent runtime)" network
120
+ exit 0
121
+ fi
122
+ if [ "$HTTP_STATUS" != "200" ]; then
123
+ case "$HTTP_STATUS" in
124
+ 401) emit_disconnected "DOCO_TOKEN expired or invalid (HTTP 401)" default ;;
125
+ 403) emit_disconnected "token cannot access this Doco (HTTP 403) — ask the project owner to add this agent as a member, or run \`doco login\` with an account that already has access. Don't run \`--create\` — there's already a Doco; you just can't reach it." default ;;
126
+ 404) emit_disconnected "DOCO_ID \"${DOCO_ID}\" doesn't resolve on doco.to (HTTP 404)" default ;;
127
+ 5*) emit_disconnected "doco.to returned ${HTTP_STATUS} — host outage; wait and retry. \`doco login\` won't help." network ;;
128
+ *) emit_disconnected "bootstrap failed with HTTP ${HTTP_STATUS}" default ;;
129
+ esac
130
+ exit 0
131
+ fi
132
+
133
+ INSTR=$(printf '%s' "$RESP" | jq -r '.canonical_instructions // empty')
134
+ if [ -z "$INSTR" ]; then
135
+ emit_disconnected "bootstrap response has no canonical_instructions field"
136
+ exit 0
137
+ fi
138
+
139
+ WARNING_TEXT=$(printf '%s' "$RESP" | jq -r '.warning // empty' 2>/dev/null)
140
+ RESP_DOCO_ID=$(printf '%s' "$RESP" | jq -r '.doco_id // empty' 2>/dev/null)
141
+ HAS_GUIDANCE=$(printf '%s' "$RESP" | jq -r '.missing_doco_guidance // empty | if type == "object" then "1" else "" end' 2>/dev/null)
142
+
143
+ # When the host says the DOCO_ID didn't resolve OR resolved-but-is-
144
+ # inaccessible, the bootstrap response carries structured recovery
145
+ # guidance under `missing_doco_guidance` plus a single-line summary
146
+ # under `warning`. Split title (indicator) from summary+actions (body)
147
+ # so the agent sees a clean "Not connected yet: <title>" line plus the
148
+ # numbered action list — instead of cramming the whole multi-paragraph
149
+ # guidance into the indicator slot.
150
+ if [ -n "$HAS_GUIDANCE" ]; then
151
+ GUIDANCE_TITLE=$(printf '%s' "$RESP" | jq -r '.missing_doco_guidance.title // empty' 2>/dev/null)
152
+ GUIDANCE_BODY=$(printf '%s' "$RESP" | jq -r '
153
+ .missing_doco_guidance as $g |
154
+ $g.summary + "\n\n"
155
+ + (
156
+ ($g.actions | to_entries | map(
157
+ ((.key + 1) | tostring) + ". " + .value.label
158
+ + (if .value.command then "\n $ " + .value.command else "" end)
159
+ + "\n " + .value.explainer
160
+ )) | join("\n\n")
161
+ )
162
+ ' 2>/dev/null)
163
+ emit_disconnected "$GUIDANCE_TITLE" verbatim "$GUIDANCE_BODY"
164
+ exit 0
165
+ fi
166
+ if [ -n "$WARNING_TEXT" ]; then
167
+ emit_disconnected "$WARNING_TEXT" verbatim
168
+ exit 0
169
+ fi
170
+ if [ "$RESP_DOCO_ID" != "$DOCO_ID" ]; then
171
+ emit_disconnected "bootstrap did not return context for ${DOCO_ID}" default
172
+ exit 0
173
+ fi
174
+
175
+ # Include the per-Doco code_map when the host returned one. Stringified
176
+ # as YAML-ish nested list under a clear header so the agent recognises it.
177
+ CODE_MAP_BLOCK=""
178
+ if printf '%s' "$RESP" | jq -e '.code_map and (.code_map | type == "object")' >/dev/null 2>&1; then
179
+ CODE_MAP_TEXT=$(printf '%s' "$RESP" | jq -r '
180
+ .code_map as $cm |
181
+ ($cm | keys[]) as $k |
182
+ "### " + $k + "\n" + (($cm[$k] | if type == "array" then map("- " + .) | join("\n") else "- " + tostring end))
183
+ ' 2>/dev/null)
184
+ if [ -n "$CODE_MAP_TEXT" ]; then
185
+ CODE_MAP_BLOCK=$(printf '\n\n---\n\n## code_map — where each feature\047s code lives\n\nSaves you a filesystem grep. Read these paths first when the user\047s task hits one of the listed features.\n\n%s\n' "$CODE_MAP_TEXT")
186
+ fi
187
+ fi
188
+
189
+ # Include the Constitution scope when the host returned one. The
190
+ # Constitution holds Doco-wide rules that block captures at the server
191
+ # boundary — surfacing it here means the agent sees what'll trip a
192
+ # 400 *before* drafting an entity, not after.
193
+ CONSTITUTION_BLOCK=""
194
+ if printf '%s' "$RESP" | jq -e '.constitution and (.constitution | type == "object")' >/dev/null 2>&1; then
195
+ CONSTITUTION_TEXT=$(printf '%s' "$RESP" | jq -r '
196
+ .constitution as $c |
197
+ "**Scope:** " + ($c.icon // "⚖️") + " `" + $c.name + "` (id: `" + $c.id + "`)\n\n"
198
+ + (if ($c.purpose // "") != "" then "**Purpose:** " + $c.purpose + "\n\n" else "" end)
199
+ + (if ($c.guidelines // "") != "" then "**Guidelines:**\n\n" + $c.guidelines + "\n\n" else "" end)
200
+ + (if (($c.rules // []) | length) > 0
201
+ then "**Rules (capture aborts with 400 on a deterministic violation):**\n\n"
202
+ + (($c.rules | map(
203
+ if .kind == "requires_edge" then
204
+ "- `requires_edge` " + .edge_type + (if .target_node_type then " → " + .target_node_type else "" end) + (if .reason then " — " + .reason else "" end)
205
+ elif .kind == "forbids_edge" then
206
+ "- `forbids_edge` " + .edge_type + (if .target_node_type then " → " + .target_node_type else "" end) + (if .reason then " — " + .reason else "" end)
207
+ elif .kind == "requires_field" then
208
+ "- `requires_field` `" + .field + "`" + (if .reason then " — " + .reason else "" end)
209
+ elif .kind == "forbids_field" then
210
+ "- `forbids_field` `" + .field + "`" + (if .reason then " — " + .reason else "" end)
211
+ elif .kind == "mandatory_scope" then
212
+ "- `mandatory_scope` → every node must list scope `" + .scope_id + "`" + (if .reason then " — " + .reason else "" end)
213
+ elif .kind == "probabilistic" then
214
+ "- `probabilistic` (LLM-judged) — " + (.spec // "")
215
+ else
216
+ "- `" + .kind + "`"
217
+ end
218
+ )) | join("\n"))
219
+ else ""
220
+ end)
221
+ ' 2>/dev/null)
222
+ if [ -n "$CONSTITUTION_TEXT" ]; then
223
+ CONSTITUTION_BLOCK=$(printf '\n\n---\n\n## constitution — this Doco\047s load-bearing rules\n\nThese rules are enforced server-side at capture time. Read them BEFORE drafting a Decision/Rule/Intent so you don\047t draft something that\047ll trip a 400.\n\n%s\n' "$CONSTITUTION_TEXT")
224
+ fi
225
+ fi
226
+
227
+ # Include the scopes manifest when the host returned one. Splits into
228
+ # mandatory (forced by the Constitution onto every node — capture aborts
229
+ # without them) and optional (pick by content). When the manifest is
230
+ # empty the block is suppressed entirely.
231
+ SCOPES_BLOCK=""
232
+ if printf '%s' "$RESP" | jq -e '.scopes and (.scopes | type == "array") and ((.scopes | length) > 0)' >/dev/null 2>&1; then
233
+ SCOPES_TEXT=$(printf '%s' "$RESP" | jq -r '
234
+ (.scopes | map(select(.is_mandatory == true))) as $mand |
235
+ (.scopes | map(select(.is_mandatory != true))) as $opt |
236
+ "### Mandatory — every node must list these (capture aborts without them)\n\n"
237
+ + (if ($mand | length) > 0
238
+ then (($mand | map(
239
+ "- " + (.icon // "🏷️") + " `" + .name + "` — " + (.purpose // "(no purpose set)")
240
+ )) | join("\n"))
241
+ else "_(none in this Doco — no `mandatory_scope` rule on the Constitution)_"
242
+ end)
243
+ + "\n\n### Optional — pick by what the node is about\n\n"
244
+ + (if ($opt | length) > 0
245
+ then (($opt | map(
246
+ "- " + (.icon // "🏷️") + " `" + .name + "` — " + (.purpose // "(no purpose set)")
247
+ + (if .lifecycle == "deprecated" then " _(deprecated)_" else "" end)
248
+ )) | join("\n"))
249
+ else "_(none)_"
250
+ end)
251
+ ' 2>/dev/null)
252
+ if [ -n "$SCOPES_TEXT" ]; then
253
+ SCOPES_BLOCK=$(printf '\n\n---\n\n## scopes — this Doco\047s topical neighborhoods\n\nEvery captured node must list at least one scope. Mandatory scopes apply to ALL nodes; optional scopes are picked by what the node is about. Full guidelines: open the scope\047s page or fetch /status.json.\n\n%s\n' "$SCOPES_TEXT")
254
+ fi
255
+ fi
256
+
257
+ # Pre-bake the session-load indicator line so the agent emits it as
258
+ # the literal first output of its first reply — BEFORE any prose,
259
+ # narration, or tool calls. Pick a random loading-verb from the
260
+ # canonical 1a list so the line is ready to paste verbatim.
261
+ LOADING_VERBS=("Connected to" "Tuned into" "Listening to" "Wired up to" "Synced with" "Plugged into" "Online with" "Reading" "Hooked into" "Eyes on" "Riding shotgun on" "Pinned to" "Threaded into" "Locked onto" "Channel open:" "Live on" "Mind-melded with" "Pulled up" "Holding the file on")
262
+ LOADING_VERB="${LOADING_VERBS[$RANDOM % ${#LOADING_VERBS[@]}]}"
263
+ DOCO_FOR_LINE="${DOCO_ID:-this Doco}"
264
+ SESSION_LOAD_LINE="[🔮 Doco] ${LOADING_VERB} ${DOCO_FOR_LINE}"
265
+
266
+ # Prepend a strong "do not re-fetch" header so the agent recognises the
267
+ # canonical is ALREADY in their context. The previous "if you see this,
268
+ # the hook worked" wording was too soft — agents re-fetched anyway. This
269
+ # version explicitly forbids re-fetching AND pre-bakes the session-load
270
+ # line the agent must emit as its first output.
271
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
272
+ HEADER=$'🔒 Doco canonical_instructions — auto-loaded by SessionStart hook at '"${TIMESTAMP}"$'\n\n⚠️ **The literal first line of your first reply must be the session-load indicator** — emitted BEFORE any prose, narration, or tool calls. Pre-built for you here (verb already randomized — paste verbatim):\n\n '"${SESSION_LOAD_LINE}"$'\n\nNo "let me read this first" preface. No "I see this repo has Doco" prose. The line IS the acknowledgement. Then your per-reply [🔮 Doco] querying / count lines, then prose. See canonical § 1a below.\n\nThis IS the canonical. **Do NOT re-fetch via raw curl** — re-read the block below instead, or use `doco bootstrap` if this block has genuinely fallen out of context. The protocol applies to every connected reply (query indicator at top, footer_lines after writes, tally at end). For deep reference (model walkthrough, scope onboarding, placement examples), the long form is at `/api/v1/agent-reference` — fetch only on demand.\n\n---\n\n'
273
+ printf '%s' "$RESP" | jq -nc --arg c "${HEADER}${INSTR}${CODE_MAP_BLOCK}${CONSTITUTION_BLOCK}${SCOPES_BLOCK}" \
274
+ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'