Compare commits
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
|
"Skill(pql)",
|
||||||
|
"Skill(clide)",
|
||||||
|
"Skill(whats-next)",
|
||||||
|
"Skill(git-commit)",
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git status *)",
|
"Bash(git status *)",
|
||||||
@@ -29,7 +33,10 @@
|
|||||||
"Bash(flutter *)",
|
"Bash(flutter *)",
|
||||||
"Bash(make *)",
|
"Bash(make *)",
|
||||||
"Bash(pql)",
|
"Bash(pql)",
|
||||||
"Bash(pql *)"
|
"Bash(pql *)",
|
||||||
|
"Bash(clide)",
|
||||||
|
"Bash(clide *)",
|
||||||
|
"Bash(awk *)"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(rm -rf /*)",
|
"Bash(rm -rf /*)",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Clide Claude-Code skills
|
||||||
|
|
||||||
|
Skills are reusable instruction packs Claude Code loads on demand. Each one
|
||||||
|
lives in its own directory with a `SKILL.md` (frontmatter + body) and any
|
||||||
|
helper scripts. This index is for humans skimming what's available; Claude
|
||||||
|
discovers skills automatically from the directory structure.
|
||||||
|
|
||||||
|
| Skill | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`d2-diagram`](d2-diagram/SKILL.md) | Generate technical diagrams from `.d2` source with the d2 CLI; renders to PNG. |
|
||||||
|
| [`frame0-wireframe`](frame0-wireframe/SKILL.md) | Author UI wireframes as local JSON and sync to the Frame0 desktop app for rendering + export. |
|
||||||
|
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (40/60 word cap), attribution trailer, safety rules. |
|
||||||
|
| [`pql`](pql/SKILL.md) | Query and plan against the markdown vault via the `pql` CLI (decisions, tickets, structural queries). |
|
||||||
|
| [`skill-create`](skill-create/SKILL.md) | Guidance for creating new skills — SKILL.md structure, bundling scripts, packaging. |
|
||||||
|
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / theme / native changes. |
|
||||||
|
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons. |
|
||||||
|
| [`whats-next`](whats-next/SKILL.md) | Dependency-driven batch selection against the pql backlog. Walks the initiative/epic tree, filters unblocked work, refines, optionally activates. |
|
||||||
|
|
||||||
|
## Adding a skill
|
||||||
|
|
||||||
|
Use the `skill-create` skill (or follow its SKILL.md by hand). Add a row to
|
||||||
|
the table above so the inventory stays accurate; the index is otherwise just
|
||||||
|
a directory listing.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "1.5.0",
|
||||||
|
"hash": "sha256:8090b99552c828ce019aa3740d8a0acb4768a443704b4e935efa23b32e91128d",
|
||||||
|
"installed_at": "2026-06-02T08:46:25Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
name: clean-house
|
||||||
|
description: >
|
||||||
|
Run a documentation-discipline pass over a DQR (Decisions / Questions /
|
||||||
|
Rejected) markdown system. Audits decisions/, surfaces drift (broken
|
||||||
|
anchor links, missing Q→D backlinks, sunset-shaped phrases without
|
||||||
|
linked tickets, files over the split threshold, dead cross-references),
|
||||||
|
batches findings by category, and uses AskUserQuestion to apply fixes
|
||||||
|
interactively. Trigger this skill whenever the user asks to clean,
|
||||||
|
tidy, audit, sweep, garden, or review the state of decisions/, the
|
||||||
|
DQR system, the questions index, or the records under decisions/.
|
||||||
|
Also trigger before a release, after a session that touched many
|
||||||
|
records, or whenever the user mentions documentation drift, stale
|
||||||
|
plans, or "house" / "clean-house" / "house cleaning". Imperative —
|
||||||
|
apply fixes, not just report.
|
||||||
|
---
|
||||||
|
|
||||||
|
# clean-house
|
||||||
|
|
||||||
|
A periodic gardening pass over `decisions/`. The DQR system is
|
||||||
|
markdown-as-source-of-truth; `.pql/pql.db` is a derived index. This skill
|
||||||
|
operates on the markdown files, batches drift by category, and uses
|
||||||
|
`AskUserQuestion` to decide what to apply.
|
||||||
|
|
||||||
|
The skill is **imperative**. It really cleans. The verb-noun match is
|
||||||
|
load-bearing — a `clean-house` that only produced a report would be
|
||||||
|
misnamed. Mechanical fixes get a single batched approval, judgment-call
|
||||||
|
findings get individual prompts, and a "stop asking" escape hatch is
|
||||||
|
always present.
|
||||||
|
|
||||||
|
You — Claude — are the runtime. Read this file, read
|
||||||
|
`references/rules.md`, follow the procedure below, call `pql` and `Edit`
|
||||||
|
to do the work, call `AskUserQuestion` for the prompts.
|
||||||
|
|
||||||
|
## When to run
|
||||||
|
|
||||||
|
- Before a release.
|
||||||
|
- After a session that touched many D / Q / R records.
|
||||||
|
- When something feels stale and a sweep is wanted.
|
||||||
|
- When `pql decisions validate` is green but `decisions/` still feels off.
|
||||||
|
|
||||||
|
This skill is **not** a replacement for `pql decisions validate`.
|
||||||
|
Validate is fast, hot, and gates pre-push. clean-house is slow, cold,
|
||||||
|
on-demand. Escalation path: rules that fire often here are candidates
|
||||||
|
for promotion to `pql decisions validate`; rules that almost never fire
|
||||||
|
stay here where false positives don't slow anyone down.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Gate on validate.** Run `pql decisions validate`. If it exits
|
||||||
|
non-zero, stop and tell the user to fix validator findings first.
|
||||||
|
Do not proceed.
|
||||||
|
|
||||||
|
2. **Load the rule catalog.** Read `references/rules.md`. Each rule
|
||||||
|
names its detection, fix, and finding-id format.
|
||||||
|
|
||||||
|
3. **Probe conventions.** Read `decisions/.clean-house.yaml` if it
|
||||||
|
exists; otherwise infer from the project. Conventions to resolve:
|
||||||
|
- `heading_level`: 2 or 3 — the depth records use (`## D-N` vs
|
||||||
|
`### D-N`). Probe by sampling the first 3 records' file
|
||||||
|
locations and looking for the first matching ATX heading.
|
||||||
|
- `backlink_phrasing`: the project's Q→D backlink phrase (default
|
||||||
|
`Resolved → D-N`; some projects use `Resolved as D-N` or
|
||||||
|
`→ D-N`).
|
||||||
|
- `file_threshold`: default 350 (RULE-FILE-OVER-THRESHOLD).
|
||||||
|
- `stale_open_q_days`: default 60 (RULE-STALE-OPEN-Q).
|
||||||
|
|
||||||
|
Pass the resolved conventions into each rule's detection. Cache
|
||||||
|
the probe result for the run.
|
||||||
|
|
||||||
|
`decisions/.clean-house.yaml` shape (all keys optional):
|
||||||
|
```yaml
|
||||||
|
heading_level: 3
|
||||||
|
backlink_phrasing: "Resolved → "
|
||||||
|
file_threshold: 500
|
||||||
|
stale_open_q_days: 90
|
||||||
|
exclude_paths: ["legacy/", "drafts/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Sync the index.** Run `pql decisions sync` so the DB reflects
|
||||||
|
the markdown.
|
||||||
|
|
||||||
|
5. **Walk decisions/.** Use `pql decisions list` to enumerate
|
||||||
|
records, plus `pql decisions read <id>` per record when a rule
|
||||||
|
needs the body. For file-level rules (size, sort), read the
|
||||||
|
markdown files directly. **Filter out** any record whose
|
||||||
|
`file_path` begins with `legacy/` (or any prefix in
|
||||||
|
`exclude_paths` from the probe).
|
||||||
|
|
||||||
|
6. **Run each rule's detection.** Collect findings. Tag each with
|
||||||
|
its rule ID, finding ID (per the rule's format), category
|
||||||
|
(`mechanical` | `judgment`), and the record(s)/file(s) involved.
|
||||||
|
|
||||||
|
7. **Group findings by rule** and prompt. Honor any "skipped 3+
|
||||||
|
times" findings from the skip ledger by escalating their prompt
|
||||||
|
("skipped 3x — promote to manual decision, downgrade to
|
||||||
|
summary-only, or keep asking?"):
|
||||||
|
|
||||||
|
- **Mechanical rules** — one prompt per rule, batched. Show the
|
||||||
|
count and offer: apply all / show diff first / skip this rule /
|
||||||
|
**stop asking** (global — see below).
|
||||||
|
- **Judgment rules** — one prompt per finding. Options match the
|
||||||
|
rule's `Fix` section, plus the same global **stop asking**.
|
||||||
|
|
||||||
|
**"Stop asking" semantics (global).** When the user picks "stop
|
||||||
|
asking" on any prompt, immediately halt all further prompts in
|
||||||
|
the run — mechanical batches not yet asked AND remaining
|
||||||
|
judgment findings. Move all unprompted findings to the skip
|
||||||
|
ledger marked `deferred-stop-asking`. Skip to step 9.
|
||||||
|
|
||||||
|
**"Show diff first" loop.** When the user picks "show diff
|
||||||
|
first" on a mechanical batch, emit a unified diff of the staged
|
||||||
|
edits (one block per file), then re-prompt the same question
|
||||||
|
with the same options minus "show diff first" — preventing
|
||||||
|
infinite loops while still letting the user inspect before
|
||||||
|
committing.
|
||||||
|
|
||||||
|
8. **Apply approvals.** For mechanical rules use `Edit` to mutate
|
||||||
|
the markdown directly. For judgment rules, follow the option the
|
||||||
|
user picked (file a ticket via `pql ticket new`, mark superseded
|
||||||
|
by editing the record, etc.).
|
||||||
|
|
||||||
|
9. **Update the skip ledger and history.** Both files live at
|
||||||
|
`decisions/.clean-house-state.md`, gitignored. The file uses
|
||||||
|
per-run section headers so it doubles as run history:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# clean-house run history
|
||||||
|
|
||||||
|
## 2026-05-06T13:42Z
|
||||||
|
fired: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
|
||||||
|
applied: RULE-ANCHOR-DRIFT(1) RULE-SUNSET-WITHOUT-TICKET(1)
|
||||||
|
skipped:
|
||||||
|
- RULE-ANCHOR-DRIFT D-8:#q-1-markdown-mirror-for-tickets
|
||||||
|
|
||||||
|
## 2026-05-04T11:10Z
|
||||||
|
fired: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Each section starts with a UTC ISO-8601 timestamp. `fired`
|
||||||
|
counts findings detected; `applied` counts findings the user
|
||||||
|
acted on; `skipped` lists `<rule-id> <finding-id>` pairs.
|
||||||
|
|
||||||
|
**Promotion-candidate computation.** Scan the most recent N=7
|
||||||
|
sections (or all sections if fewer). A rule is a promotion
|
||||||
|
candidate if it `fired` in ≥ 5 of those sections. Surface the
|
||||||
|
candidates in the summary; do not auto-promote.
|
||||||
|
|
||||||
|
10. **Emit the summary** (see Output below).
|
||||||
|
|
||||||
|
## Question phrasing
|
||||||
|
|
||||||
|
Always include the rule ID in the prompt so the user learns what's
|
||||||
|
being checked.
|
||||||
|
|
||||||
|
**Mechanical (batched):**
|
||||||
|
> RULE-ANCHOR-DRIFT: 12 cross-reference anchors point to headings that
|
||||||
|
> no longer exist (renamed or deleted). Apply all 12 fixes? Options:
|
||||||
|
> apply all / show diff first / skip this rule / stop asking, summarize
|
||||||
|
> the rest.
|
||||||
|
|
||||||
|
**Judgment (individual):**
|
||||||
|
> RULE-SUNSET-WITHOUT-TICKET: D-59 mentions "must track dugite-native
|
||||||
|
> releases for security updates" but has no linked T. Options: file T
|
||||||
|
> now / draft a T description first / mark as already covered (add note
|
||||||
|
> to D) / skip / stop asking.
|
||||||
|
|
||||||
|
Phrasing rules:
|
||||||
|
|
||||||
|
- **Always include "stop asking, summarize the rest."** Sessions
|
||||||
|
without bandwidth for full review need an escape. Forcing answers
|
||||||
|
to every question kills the tool.
|
||||||
|
- **Always include "skip."** Skipped findings go to the ledger; three
|
||||||
|
consecutive skips of the same finding promote the next prompt.
|
||||||
|
- **Mechanical batches; judgment doesn't.** "Apply all 12 fixes?" is
|
||||||
|
one decision against a clear delta. Twelve individual prompts defeat
|
||||||
|
the point. Conversely, judgment findings are each separate work —
|
||||||
|
presenting them as a batch hides cost.
|
||||||
|
|
||||||
|
## What this skill does NOT do
|
||||||
|
|
||||||
|
- Does not file tickets without asking.
|
||||||
|
- Does not rewrite D-record body prose, only metadata fields and
|
||||||
|
cross-reference links.
|
||||||
|
- Does not touch records under `legacy/`.
|
||||||
|
- Does not run `pql decisions validate` *as a fix* — only as a
|
||||||
|
precondition gate.
|
||||||
|
- Does not promote rules to `pql decisions validate` automatically.
|
||||||
|
Promotion is a human decision; this skill only flags candidates
|
||||||
|
("RULE-X has fired in 5 of the last 7 runs — consider promoting").
|
||||||
|
|
||||||
|
## Non-interactive context
|
||||||
|
|
||||||
|
If invoked without an interactive `AskUserQuestion` surface (a CI run,
|
||||||
|
a batch script), refuse to apply judgment fixes. Apply mechanical
|
||||||
|
fixes only if the user explicitly says so via the trigger phrase
|
||||||
|
("auto-apply mechanical, skip judgment"); otherwise emit the summary
|
||||||
|
and exit without mutating files.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
At the end, emit a summary block:
|
||||||
|
|
||||||
|
```
|
||||||
|
clean-house — sweep complete
|
||||||
|
────────────────────────────
|
||||||
|
Files scanned: 11
|
||||||
|
Records parsed: 62 D, 23 Q, 11 R
|
||||||
|
Conventions: heading_level=3 backlink="Resolved → "
|
||||||
|
Mechanical fixes: 14 applied, 0 deferred
|
||||||
|
Judgment findings: 3 acted on, 2 deferred (in state file)
|
||||||
|
Promotion candidates: RULE-ANCHOR-DRIFT (5/7 runs)
|
||||||
|
Touched files: decisions/architecture.md, decisions/questions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If this is the first run (no prior history) or there are fewer
|
||||||
|
than 5 prior runs, `Promotion candidates:` reports
|
||||||
|
`(none — N/7 runs of history)` instead of decorative empty content.
|
||||||
|
|
||||||
|
The summary names the touched files so the next commit message can be
|
||||||
|
honest about what changed. The skill does not commit on its own.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
clean-house ships embedded in the pql binary; its version is the
|
||||||
|
pql version (`pql --version`). To see what changed and when, read
|
||||||
|
`CHANGELOG.md` or `git log internal/skill/clean-house/` in the pql
|
||||||
|
repo. Keep this file and `references/rules.md` consistent with
|
||||||
|
each other when iterating — they're the contract.
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# clean-house rule catalog
|
||||||
|
|
||||||
|
The rules clean-house runs and the reasoning behind each. New rules
|
||||||
|
land here as their own entry. Catalog churn — additions, refinements,
|
||||||
|
retirements — is tracked in pql's `CHANGELOG.md` and `git log
|
||||||
|
internal/skill/clean-house/`. When a rule is retired, leave a
|
||||||
|
**Retired** stub (ID + one-line reason + retirement commit) so the
|
||||||
|
trail of "we used to check X" is recoverable from this file alone.
|
||||||
|
|
||||||
|
## Reading a rule
|
||||||
|
|
||||||
|
Each entry below has:
|
||||||
|
|
||||||
|
- **ID** — Stable identifier (e.g. `RULE-ANCHOR-DRIFT`). Used in
|
||||||
|
AskUserQuestion prompts and skip-ledger entries so the user learns
|
||||||
|
what's being checked.
|
||||||
|
- **Category** — `mechanical` or `judgment`. Drives whether findings
|
||||||
|
batch into one prompt or each get their own.
|
||||||
|
- **Finding ID** — Format string for the per-finding stable
|
||||||
|
identifier. Skip-ledger entries are written as `<rule-id>
|
||||||
|
<finding-id> <ISO-date>`; subsequent runs use the same format to
|
||||||
|
recognize "same finding skipped 3 runs in a row" and escalate.
|
||||||
|
Use only stable inputs (record IDs, file paths, slug strings, body
|
||||||
|
hashes) — never line numbers or timestamps.
|
||||||
|
- **Detection** — Concrete steps to find violations. Names the
|
||||||
|
pql command or file primitive used.
|
||||||
|
- **Fix** — Mechanical: deterministic action. Judgment: the prompt
|
||||||
|
option list and what each option does.
|
||||||
|
- **Why** — One paragraph on the failure mode this rule guards
|
||||||
|
against. Future-you reading the rule benefits from the reasoning,
|
||||||
|
not just the check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-ANCHOR-DRIFT
|
||||||
|
|
||||||
|
**Category:** mechanical
|
||||||
|
|
||||||
|
**Finding ID:** `<source-record>:<link-target>` (e.g.
|
||||||
|
`D-8:#q-1-markdown-mirror-for-tickets` or
|
||||||
|
`D-8:questions.md#q-1`).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
Anchor-only markdown links (`[text](#slug)`) resolve against the
|
||||||
|
**source file's full heading set**, not the body of a single record
|
||||||
|
— record-level headings (`### D-N: …`) live as siblings in the
|
||||||
|
file and are valid link targets. The previous detection (which used
|
||||||
|
`pql decisions read`'s body-only `headings` array) missed these
|
||||||
|
and produced false positives.
|
||||||
|
|
||||||
|
Per source body:
|
||||||
|
|
||||||
|
1. Open the source body's file (`<vault>/<file_path>` from
|
||||||
|
`pql decisions list`) and extract every ATX heading. Build a
|
||||||
|
slug index for the file using the GFM convention (lowercase,
|
||||||
|
hyphenate spaces, drop punctuation, disambiguate duplicates with
|
||||||
|
`-1`/`-2`). Cache per file — every record in that file uses the
|
||||||
|
same index.
|
||||||
|
2. For each `[text](target)` link in the body:
|
||||||
|
- Anchor-only (`#slug`): check `slug` against the **source file's**
|
||||||
|
index. Missing → flag.
|
||||||
|
- Cross-file (`path.md#slug`): resolve `path.md` relative to the
|
||||||
|
source file's directory; check `slug` against that file's
|
||||||
|
index. Missing file or missing slug → flag.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
If the slug exists in a sibling file's index (same directory) and
|
||||||
|
the link is anchor-only, rewrite to `path.md#slug` (the canonical
|
||||||
|
cross-file form). If the slug exists in the source file's index
|
||||||
|
with edit-distance ≤ 2 from the link target, rewrite to the
|
||||||
|
matched slug. Otherwise downgrade to judgment ("no auto-fix; the
|
||||||
|
heading was removed, not renamed") with options: drop the link /
|
||||||
|
point elsewhere / mark as intentionally dangling.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
If exactly one heading with a closely-matching slug exists (slug
|
||||||
|
edit-distance ≤ 2 or substring match), rewrite the link to point at
|
||||||
|
that heading. If no plausible match exists, downgrade the finding to
|
||||||
|
judgment ("no auto-fix; the heading was removed, not renamed") and
|
||||||
|
prompt the user with options: drop the link / point elsewhere / mark
|
||||||
|
as intentionally dangling.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
When a heading is renamed for clarity, every cross-reference pointing
|
||||||
|
at the old slug silently breaks. The rendered docs still look fine —
|
||||||
|
the link just goes nowhere. Without periodic sweeps these decay
|
||||||
|
indefinitely; the cost of the sweep is small compared to the cost of
|
||||||
|
a reader following a dead link and losing trust in the index.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-MISSING-Q-BACKLINK
|
||||||
|
|
||||||
|
**Category:** mechanical
|
||||||
|
|
||||||
|
**Finding ID:** `<d-id>:<q-id>` (e.g. `D-7:Q-2`). Order is always
|
||||||
|
D-first regardless of which side is missing the backlink.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each D record body, find lines of the shape `Resolves: Q-N`
|
||||||
|
(plain text, typically in the metadata block at the top of the
|
||||||
|
record). For each Q-N referenced, fetch that Q record's body via
|
||||||
|
`pql decisions read Q-N` and check whether it contains a line of the
|
||||||
|
shape `Resolved → D-N` (or equivalent backlink phrasing — match the
|
||||||
|
project's own convention; default pattern is `Resolved → D-N`).
|
||||||
|
|
||||||
|
Also check the reverse direction: any Q record claiming
|
||||||
|
`Resolved → D-N` whose D-N body lacks `Resolves: Q-N`.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
Insert the missing backlink in-place using `Edit`:
|
||||||
|
|
||||||
|
- Missing on the D side: add `Resolves: Q-N` to the D's metadata
|
||||||
|
block (typically right after `Domain:` / `Status:` lines).
|
||||||
|
- Missing on the Q side: append `Resolved → D-N` to the Q's status
|
||||||
|
line (or in the conventional position for that project).
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Bidirectional Q↔D links are the navigation backbone of the DQR
|
||||||
|
system. When one side drifts, search-by-decision finds nothing for
|
||||||
|
that question and search-by-question doesn't surface its resolution.
|
||||||
|
The asymmetric state usually arises from a hand-edit on one record
|
||||||
|
that forgot to update the other; the fix is purely mechanical because
|
||||||
|
the correct content is already determined by the existing pointer
|
||||||
|
in the other direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-RECORD-SORT
|
||||||
|
|
||||||
|
**Category:** mechanical
|
||||||
|
|
||||||
|
**Finding ID:** `<file-path>` (e.g. `decisions/architecture.md`).
|
||||||
|
The whole file is one finding — sort applies to the file as a
|
||||||
|
unit.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each `decisions/*.md` file, read it directly (no pql) and find
|
||||||
|
all top-level `## D-N` / `## Q-N` / `## R-N` headings in order. Strip
|
||||||
|
the prefix, parse the numeric suffix, and check whether the sequence
|
||||||
|
is monotonically ascending within each ID family (D, Q, R kept
|
||||||
|
separate — files commonly mix families).
|
||||||
|
|
||||||
|
Only flag a file if it is **otherwise tidy** — currently no out-of-
|
||||||
|
order amendments interleaved with new records. The heuristic: if the
|
||||||
|
sort would touch fewer than three records out of position, apply it;
|
||||||
|
if more, downgrade to judgment ("this file looks deliberately
|
||||||
|
arranged — confirm before reordering"). Three is a soft threshold; if
|
||||||
|
the file's last commit message contains `WIP` or `do-not-sort`, skip.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
Reorder the records within the file so each ID family is ascending.
|
||||||
|
Preserve everything else (headings between record blocks, any prose
|
||||||
|
prelude/postlude). Use `Edit` with full block replacement, not in-
|
||||||
|
place line shuffling — easier to verify the diff.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Records added at the bottom of a file are easy to write but hard to
|
||||||
|
find. Sorted IDs let a reader find D-37 by jumping to the
|
||||||
|
two-thirds mark of the file rather than scanning. The cost of the
|
||||||
|
sort is one reordering pass; the benefit is paid back on every
|
||||||
|
subsequent read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-EOF-NORMALIZATION
|
||||||
|
|
||||||
|
**Category:** mechanical
|
||||||
|
|
||||||
|
**Finding ID:** `<file-path>` (one finding per file).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each `decisions/*.md` file (and any other markdown the skill
|
||||||
|
touched during this run), read directly. Flag if:
|
||||||
|
|
||||||
|
- File does not end with exactly one `\n`.
|
||||||
|
- Any line contains trailing whitespace before its `\n`.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
Trim trailing whitespace from each line. Ensure exactly one trailing
|
||||||
|
newline at end of file. Use `Edit` only if a violation was found —
|
||||||
|
this rule must not produce a no-op diff.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Editor and git config drift causes whitespace creep that's invisible
|
||||||
|
in rendering but pollutes diffs (every record edit ends up touching
|
||||||
|
unrelated lines). A periodic normalization keeps future diffs clean
|
||||||
|
without forcing per-editor enforcement on every contributor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-SUNSET-WITHOUT-TICKET
|
||||||
|
|
||||||
|
**Category:** judgment
|
||||||
|
|
||||||
|
**Finding ID:** `<record-id>:<phrase-hash-8>` where phrase-hash-8 is
|
||||||
|
the first 8 hex chars of `sha256(<matched-phrase>)`. Lets the
|
||||||
|
ledger distinguish two sunset phrases in the same record.
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each D record body (via `pql decisions read <id>`), grep for
|
||||||
|
sunset-shaped phrases. Default regex set:
|
||||||
|
|
||||||
|
```
|
||||||
|
(?i)\b(delete|remove|sunset|kill[ -]?switch|tear[ -]?down) when\b
|
||||||
|
(?i)\bmust (track|monitor|watch|follow)\b
|
||||||
|
(?i)\brevisit (when|after|once)\b
|
||||||
|
(?i)\b(deprecate|retire) (when|after|once)\b
|
||||||
|
```
|
||||||
|
|
||||||
|
For each match, run `pql ticket list --decision <id>`. If no tickets
|
||||||
|
exist, the D has work-shaped intent without a tracked T — flag.
|
||||||
|
|
||||||
|
**Fix (prompt options):**
|
||||||
|
|
||||||
|
- **File T now** — Run `pql ticket new task "<phrase>" --decision <id>`,
|
||||||
|
then prompt the user for a description (or call `pql ticket refine
|
||||||
|
write <T> --description ...` after creation).
|
||||||
|
- **Draft a T description first** — Open an `AskUserQuestion` for the
|
||||||
|
description, then file as above.
|
||||||
|
- **Mark as already covered** — Add a note to the D body
|
||||||
|
("Tracked under T-N") via `Edit`. Skill does not assert which T;
|
||||||
|
user provides the ID.
|
||||||
|
- **Skip** — Adds to the ledger.
|
||||||
|
- **Stop asking** — Skip remaining sunset findings, summarize.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Sunset-shaped intent ("we'll handle X when Y happens") is the most
|
||||||
|
common source of accumulated debt in a DQR system: the trigger
|
||||||
|
condition arrives, nobody remembers the D, the work doesn't happen.
|
||||||
|
Linking each sunset to a T is the simplest defense — a T is a
|
||||||
|
backlog item that surfaces in `plan whatsnext` / `plan board`. The
|
||||||
|
fix is judgment because the right action depends on whether the D's
|
||||||
|
condition is still relevant, whether it's already covered, and what
|
||||||
|
the right scope is for the resulting T.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-FILE-OVER-THRESHOLD
|
||||||
|
|
||||||
|
**Category:** judgment
|
||||||
|
|
||||||
|
**Finding ID:** `<file-path>` (one finding per file; threshold
|
||||||
|
choice is judgment, not a per-line problem).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each `decisions/*.md` file, count lines (via `wc -l` or
|
||||||
|
equivalent). Default threshold: **350 lines**. Configurable per
|
||||||
|
project — read `decisions/.clean-house.yaml` if present, key
|
||||||
|
`file_threshold`. Fall back to 350.
|
||||||
|
|
||||||
|
**Fix (prompt options):**
|
||||||
|
|
||||||
|
- **Split now (which axis?)** — Prompt for the split axis: by ID
|
||||||
|
family (D/Q/R), by domain, by date range, custom. Then perform the
|
||||||
|
split: create new file(s), move records, update any anchor links
|
||||||
|
pointing into the moved records (run RULE-ANCHOR-DRIFT in apply
|
||||||
|
mode against the affected files after the split).
|
||||||
|
- **Accept and raise the threshold** — Update
|
||||||
|
`decisions/.clean-house.yaml`'s `file_threshold` to the next
|
||||||
|
reasonable round number above the current line count.
|
||||||
|
- **Defer** — Skip until next run.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
A single decisions file growing past ~350 lines is the point at which
|
||||||
|
linear scanning starts to lose to grep, and where the cost of
|
||||||
|
splitting (renaming anchors) is still small. Beyond ~600 lines the
|
||||||
|
split cost compounds. The threshold is judgment because some projects
|
||||||
|
deliberately keep one file per domain and accept the size; others
|
||||||
|
split aggressively. The skill surfaces the question; it doesn't
|
||||||
|
decide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-DEAD-FILE-REFERENCE
|
||||||
|
|
||||||
|
**Category:** judgment
|
||||||
|
|
||||||
|
**Finding ID:** `<record-id>:<path-token>` (path token as written
|
||||||
|
in the body, not resolved).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
For each D record body (via `pql decisions read <id>`), grep for
|
||||||
|
path-shaped tokens. Default regex:
|
||||||
|
|
||||||
|
```
|
||||||
|
\b([\w./-]+\.(md|go|py|sql|yaml|yml|toml))\b
|
||||||
|
```
|
||||||
|
|
||||||
|
The first detection pass produced 6/6 false positives in
|
||||||
|
real-world use; the regex is necessary but not sufficient. For
|
||||||
|
each match, apply the filters below in order — if any matches,
|
||||||
|
**skip without flagging**:
|
||||||
|
|
||||||
|
1. **Placeholder filter.** Token contains `T-NNN`, `D-NNN`,
|
||||||
|
`Q-NNN`, `R-NNN`, `...`, `<`, `>`, or `*` — it's a pattern,
|
||||||
|
not a real path.
|
||||||
|
2. **Source-relative resolution.** Resolve the token against the
|
||||||
|
source file's directory (`<vault>/<file_path>`'s dir). If
|
||||||
|
`[ -e <resolved> ]`, the reference is live.
|
||||||
|
3. **Repo-relative resolution.** If `[ -e <token> ]` from the
|
||||||
|
repo root, the reference is live.
|
||||||
|
4. **Basename-fallback.** Run `find <repo-root> -name <basename>`
|
||||||
|
(where basename is the last path component). If exactly one
|
||||||
|
match exists, treat the reference as live and emit a low-
|
||||||
|
priority "consider rewriting to the absolute path" note (not a
|
||||||
|
judgment finding). If multiple matches exist, do not flag —
|
||||||
|
the token is too ambiguous.
|
||||||
|
|
||||||
|
Only after all four filters miss does the reference qualify as
|
||||||
|
truly dead and warrant a judgment prompt.
|
||||||
|
|
||||||
|
**Fix (prompt options):**
|
||||||
|
|
||||||
|
- **Update reference** — Prompt for the new path; rewrite the
|
||||||
|
reference via `Edit`. If the user types a path, validate it exists
|
||||||
|
before applying.
|
||||||
|
- **Mark superseded** — Add a note to the D ("This decision
|
||||||
|
references files no longer present; the underlying constraint
|
||||||
|
was retired by D-N") and prompt for the superseding D ID.
|
||||||
|
- **Defer** — Skip until next run.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Decisions reference code paths to ground their reasoning in the
|
||||||
|
codebase that motivated them. When the code moves or gets deleted,
|
||||||
|
the D's reasoning becomes harder to verify. Dead refs are a signal
|
||||||
|
that either the decision should be updated or the decision itself is
|
||||||
|
no longer load-bearing — both are judgment calls the user needs to
|
||||||
|
make.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RULE-STALE-OPEN-Q
|
||||||
|
|
||||||
|
**Category:** judgment
|
||||||
|
|
||||||
|
**Finding ID:** `<q-id>` (one finding per Q-record; staleness
|
||||||
|
threshold is global per run, so a Q is either stale or not).
|
||||||
|
|
||||||
|
**Detection:**
|
||||||
|
|
||||||
|
Run `pql decisions list --type question --status open`. For each
|
||||||
|
returned record, check the `date` field. Default staleness threshold:
|
||||||
|
**60 days** (configurable via `decisions/.clean-house.yaml` key
|
||||||
|
`stale_open_q_days`). Flag any Q with `date` older than the
|
||||||
|
threshold.
|
||||||
|
|
||||||
|
**Fix (prompt options):**
|
||||||
|
|
||||||
|
- **Still relevant** — Touch the Q's `date` field to today, optionally
|
||||||
|
add a one-line "still open as of YYYY-MM-DD: <reason>" to the body.
|
||||||
|
- **Nudge to load-bearing index** — If the project keeps a
|
||||||
|
load-bearing Qs list (a curated index of unresolved questions
|
||||||
|
affecting current work), prompt for inclusion and add the Q there.
|
||||||
|
- **Mark withdrawn** — Change the Q's status to `withdrawn` (the DQR
|
||||||
|
system's terminal state for "no longer worth answering"); the
|
||||||
|
user supplies a one-line reason.
|
||||||
|
- **Defer** — Skip until next run.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Open questions are useful when they're current; stale ones become
|
||||||
|
ambient noise that drowns the signal of new questions. Most
|
||||||
|
projects don't enforce explicit Q-lifecycle, so without a periodic
|
||||||
|
prompt the open list grows forever. The right action depends on
|
||||||
|
whether the question is still open in fact — not just in metadata —
|
||||||
|
which only the user can confirm.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: clide
|
||||||
|
description: >
|
||||||
|
Use when you are running inside the clide IDE and want to observe or drive
|
||||||
|
its live UI — panes, editor, files, git, readers, toasts, layout — through
|
||||||
|
the `clide` CLI, or to find out what commands clide exposes. clide is the
|
||||||
|
IDE hosting this session; it puts `clide` on your PATH and a per-workspace
|
||||||
|
socket in `CLIDE_SOCK`. Start with `clide capabilities` to enumerate the
|
||||||
|
live tool surface. Triggers: "what can clide do", "drive the clide UI",
|
||||||
|
"open this in clide", "show the user", "toast", or invoking /clide.
|
||||||
|
user-invocable: true
|
||||||
|
allowed-tools: Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
# Driving clide from the CLI
|
||||||
|
|
||||||
|
You are (often) running **inside clide** — a Flutter IDE that hosts this
|
||||||
|
Claude session. It exposes its whole UI surface as a `clide <subsystem> <verb>`
|
||||||
|
CLI on your PATH, talking to the running app over a per-workspace socket
|
||||||
|
(`CLIDE_SOCK`). Every UI action the user can take has a CLI verb, and every
|
||||||
|
verb's effect is observable — that is the parity contract (D-6). So you can
|
||||||
|
*see what the user sees* and *show the user what you mean*.
|
||||||
|
|
||||||
|
## Discover the surface first — don't hard-code it
|
||||||
|
|
||||||
|
The authoritative, always-current list of commands is the app itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
clide capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
It returns JSON: every registered command, split into `subsystem` + `verb`,
|
||||||
|
with its argument schema (`positional` order + per-arg `type`/`required`/
|
||||||
|
constraints) where one is declared. **Sourced from the live dispatcher
|
||||||
|
registry, so it never drifts.** New panels/verbs appear here the moment they
|
||||||
|
register — re-run it instead of trusting a remembered list (including this
|
||||||
|
one). `clide <subsystem>` with no verb, or an unknown command, prints usage.
|
||||||
|
|
||||||
|
## The two halves of parity
|
||||||
|
|
||||||
|
- **Observe** — read the live UI state:
|
||||||
|
- `clide status` — one-shot orientation: workspace, git, active editor
|
||||||
|
buffer + selection, open reader docs, the panes the user sees, layout.
|
||||||
|
- `clide pane list`, `clide editor active` — narrower snapshots.
|
||||||
|
- **Drive** — make the UI do something:
|
||||||
|
- `clide ui open <reader> <ref>` — open a doc in a GUI reader
|
||||||
|
(`tickets`/`decisions` by id, `markdown`/`diff` by path): "look at this
|
||||||
|
with me." `diff <path>` reveals the diff tab and scrolls to that file.
|
||||||
|
- `clide ui toast "message" [--severity success|warning|error|info]` — raise
|
||||||
|
a toast on the user's screen: "tests green", "push failed".
|
||||||
|
- pane/editor/files/git verbs — see `clide capabilities` for the current set
|
||||||
|
and their args.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Slots:** the layout has three content slots — `sidebar` (left), `workspace`
|
||||||
|
(center, where Claude lives), `context` (right) — plus the bottom `statusbar`.
|
||||||
|
Many verbs take a slot.
|
||||||
|
- **Honest failures:** a drive verb with no live GUI returns a `toolError`
|
||||||
|
("no live UI to drive"), not a hang. JSON on stdout; exit code conveys
|
||||||
|
ok/usage/tool error.
|
||||||
|
- **You only see what flows through clide.** Your own non-`clide` shell work
|
||||||
|
(plain file reads, `make test`, `git`) is outside clide's view by design
|
||||||
|
(D-83) — run it *through* `clide …` if you want clide to observe it.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: commit
|
||||||
|
description: Create a well-formatted git commit with staged changes
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Commit
|
||||||
|
|
||||||
|
Create a well-formatted commit with staged changes following best practices.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Run `git status --porcelain` to check for changes
|
||||||
|
2. If no staged changes, show unstaged files and ask what to stage
|
||||||
|
3. Run `git diff --cached` to review staged changes
|
||||||
|
4. Generate a commit message following Conventional Commits format:
|
||||||
|
- `feat:` new feature
|
||||||
|
- `fix:` bug fix
|
||||||
|
- `docs:` documentation
|
||||||
|
- `refactor:` code restructuring
|
||||||
|
- `test:` adding tests
|
||||||
|
- `chore:` maintenance
|
||||||
|
5. Create commit with the message, adding Co-Authored-By trailer
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short description>
|
||||||
|
|
||||||
|
<body - what and why, not how>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Warn about large commits (>500 lines changed)
|
||||||
|
- Suggest splitting large changes into smaller commits
|
||||||
|
- Never skip pre-commit hooks unless explicitly requested
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
After committing, update `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format:
|
||||||
|
|
||||||
|
1. Add entry under `[Unreleased]` section (create if not exists)
|
||||||
|
2. Categorize changes:
|
||||||
|
- `Added` - new features
|
||||||
|
- `Changed` - changes to existing functionality
|
||||||
|
- `Deprecated` - features marked for removal
|
||||||
|
- `Removed` - removed features
|
||||||
|
- `Fixed` - bug fixes
|
||||||
|
- `Security` - security-related changes
|
||||||
|
3. Write entries in imperative mood: "Add feature" not "Added feature"
|
||||||
|
4. Reference issue numbers where applicable
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
name: d2-diagram
|
||||||
|
description: >
|
||||||
|
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
|
||||||
|
user says "create a diagram", "draw architecture", "make a flowchart",
|
||||||
|
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
|
||||||
|
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
|
||||||
|
Produces .d2 source files and renders them to PNG. Also use when asked
|
||||||
|
to update, re-render, or batch render existing diagrams.
|
||||||
|
---
|
||||||
|
|
||||||
|
# d2 Diagram Generation
|
||||||
|
|
||||||
|
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
|
||||||
|
external dependencies beyond the d2 binary.
|
||||||
|
|
||||||
|
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
|
||||||
|
|
||||||
|
## Project Defaults
|
||||||
|
|
||||||
|
| Setting | Value | Override |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| Theme | 200 (Dark Mauve) | `--theme N` |
|
||||||
|
| Layout | dagre | `--layout elk` |
|
||||||
|
| Padding | 100px | — |
|
||||||
|
| Format | PNG | `--svg` |
|
||||||
|
|
||||||
|
## Output Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/diagrams/
|
||||||
|
architecture/ # System architecture, IPC, component layout
|
||||||
|
data-flow/ # Sequence diagrams, data pipelines
|
||||||
|
entity/ # ER diagrams, ECS component schemas
|
||||||
|
state/ # State machines, behavior trees
|
||||||
|
ui/ # UI navigation flow, screen transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `.d2` source and `.png` output are tracked in git.
|
||||||
|
|
||||||
|
## Single Diagram Workflow
|
||||||
|
|
||||||
|
1. **Determine category** — architecture, data-flow, entity, state, or ui
|
||||||
|
2. **Read template** — `references/diagram-templates.md` for the matching category
|
||||||
|
3. **Read syntax** — `references/d2-syntax-guide.md` if unfamiliar with d2 syntax
|
||||||
|
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
|
||||||
|
5. **Validate** — `.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
|
||||||
|
6. **Render** — `.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
|
||||||
|
7. **Read SVG** — verify the output, present to user
|
||||||
|
|
||||||
|
### Script Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Render with project defaults
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Validate syntax only
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Auto-format source
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Sketch mode (hand-drawn look for drafts)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
|
||||||
|
|
||||||
|
# Light theme (for printable docs)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
|
||||||
|
|
||||||
|
# SVG output (if specifically needed)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Render
|
||||||
|
|
||||||
|
Re-render all diagrams after theme or style changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All diagrams
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh
|
||||||
|
|
||||||
|
# One category
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
|
||||||
|
|
||||||
|
# Preview what would render
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
|
||||||
|
|
||||||
|
# Force re-render everything
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Variables for consistent styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#2a3040"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-board (layers)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base diagram here
|
||||||
|
|
||||||
|
layers: {
|
||||||
|
detailed: {
|
||||||
|
# More detailed view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequence diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
client: Godot Client
|
||||||
|
server: Rust Server
|
||||||
|
|
||||||
|
client -> server: TickRequest(delta)
|
||||||
|
server -> client: WorldState(entities)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Split shared definitions into a separate file and import:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
...@shared-defs.d2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Guidance
|
||||||
|
|
||||||
|
- **Qatux** — Architecture decision records, system overview diagrams, data
|
||||||
|
schemas. Prefer architecture and entity templates.
|
||||||
|
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
|
||||||
|
system data flow. Prefer architecture and data-flow templates.
|
||||||
|
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
|
||||||
|
Prefer UI flow template.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
|
||||||
|
containers, styling, variables). Read when unfamiliar with d2 syntax.
|
||||||
|
- `references/diagram-templates.md` — Five category templates with complete
|
||||||
|
d2 source examples. Read when starting a new diagram.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# D2 Language Quick Reference
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
```d2
|
||||||
|
server # Implicit label from key
|
||||||
|
server: Simulation Server # Explicit label
|
||||||
|
server: Simulation Server { # With properties
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: "#2d3436"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edges
|
||||||
|
|
||||||
|
```d2
|
||||||
|
a -> b # Directed
|
||||||
|
a <- b # Reverse directed
|
||||||
|
a <-> b # Bidirectional
|
||||||
|
a -- b # Undirected
|
||||||
|
a -> b: "label" # Labeled edge
|
||||||
|
a -> b -> c # Chained
|
||||||
|
```
|
||||||
|
|
||||||
|
## Containers (nesting)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
infrastructure: {
|
||||||
|
server: Simulation Server
|
||||||
|
database: State Store {
|
||||||
|
shape: cylinder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
| Shape | Use for |
|
||||||
|
|-------|---------|
|
||||||
|
| `rectangle` | Default. Components, modules, generic. |
|
||||||
|
| `hexagon` | Systems, services, major components. |
|
||||||
|
| `cylinder` | Databases, storage, persistent state. |
|
||||||
|
| `diamond` | Decisions, conditions, branch points. |
|
||||||
|
| `oval` / `circle` | Start/end states, events. |
|
||||||
|
| `cloud` | External systems, networks. |
|
||||||
|
| `person` | Actors, users, NPCs. |
|
||||||
|
| `queue` | Message queues, buffers. |
|
||||||
|
| `page` | Documents, files. |
|
||||||
|
| `package` | Packages, modules, crates. |
|
||||||
|
| `sql_table` | Database tables, ECS component schemas. |
|
||||||
|
| `class` | Class diagrams, ECS system definitions. |
|
||||||
|
| `code` | Code blocks (set `language` property). |
|
||||||
|
| `markdown` | Rich text blocks. |
|
||||||
|
|
||||||
|
## SQL Tables
|
||||||
|
|
||||||
|
```d2
|
||||||
|
entity: {
|
||||||
|
shape: sql_table
|
||||||
|
id: u64 {constraint: primary_key}
|
||||||
|
name: String
|
||||||
|
position: Vec2
|
||||||
|
faction_id: u64 {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Class Diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
perception_system: {
|
||||||
|
shape: class
|
||||||
|
+run(world: &mut World)
|
||||||
|
-calculate_los(entity: Entity): HashSet<Entity>
|
||||||
|
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequence Diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
client: Godot Client
|
||||||
|
server: Rust Server
|
||||||
|
|
||||||
|
client -> server: TickRequest(delta)
|
||||||
|
server -> server: run ECS systems
|
||||||
|
server -> client: WorldState(entities)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
node: Label {
|
||||||
|
style: {
|
||||||
|
fill: "#2d3436"
|
||||||
|
stroke: "#333340"
|
||||||
|
stroke-width: 2
|
||||||
|
stroke-dash: 5 # Dashed line
|
||||||
|
opacity: 0.8
|
||||||
|
font-size: 14
|
||||||
|
font-color: "#c8d0e0"
|
||||||
|
bold: true
|
||||||
|
italic: false
|
||||||
|
border-radius: 4
|
||||||
|
shadow: true
|
||||||
|
3d: true # Rectangles only
|
||||||
|
multiple: true # Stacked appearance
|
||||||
|
double-border: true # Rectangles/ovals only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
a -> b: {
|
||||||
|
style: {
|
||||||
|
stroke: "#c8d8f0"
|
||||||
|
stroke-width: 2
|
||||||
|
stroke-dash: 5
|
||||||
|
opacity: 0.8
|
||||||
|
animated: true # Animated flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#1a1e24"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
node: {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.stroke: ${color-stroke}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right # left-to-right (default for dagre)
|
||||||
|
direction: down # top-to-bottom
|
||||||
|
direction: up
|
||||||
|
direction: left
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```d2
|
||||||
|
...@shared-defs.d2 # Spread import (inline all definitions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
```d2
|
||||||
|
node: Label {
|
||||||
|
icon: https://icons.terrastruct.com/essentials/time.svg
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layers (multi-board)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base diagram content here
|
||||||
|
|
||||||
|
layers: {
|
||||||
|
detailed: {
|
||||||
|
# More detailed view
|
||||||
|
}
|
||||||
|
simplified: {
|
||||||
|
# Simplified overview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenarios (animated transitions)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base state
|
||||||
|
|
||||||
|
scenarios: {
|
||||||
|
alert: {
|
||||||
|
# Changes from base for alert state
|
||||||
|
}
|
||||||
|
combat: {
|
||||||
|
# Changes from base for combat state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# This is a comment
|
||||||
|
node: Label # Inline comment
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Colors (from visual-grammar-v01.md)
|
||||||
|
|
||||||
|
| Constant | Hex | Usage |
|
||||||
|
|----------|-----|-------|
|
||||||
|
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
|
||||||
|
| Zone 1 wall | `#2a3040` | Component fill |
|
||||||
|
| Outline standard | `#333340` | Borders, strokes |
|
||||||
|
| Insert chrome | `#c8d0e0` | Text, labels |
|
||||||
|
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Diagram Templates
|
||||||
|
|
||||||
|
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Diagram
|
||||||
|
|
||||||
|
System components, relationships, communication channels.
|
||||||
|
|
||||||
|
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
|
||||||
|
|
||||||
|
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#2a3040"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
client: Godot Client {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
rendering: Rendering {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
ui: UI Layer {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
bridge: IPC Bridge {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server: Rust Server {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
ecs: bevy_ecs {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
perception: Perception {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
bridge: IPC Bridge {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.bridge -> server.bridge: "stdin/stdout" {
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
style.stroke-dash: 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Entity Relationship
|
||||||
|
|
||||||
|
Data schemas, ECS components, knowledge graph structure.
|
||||||
|
|
||||||
|
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
|
||||||
|
|
||||||
|
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
entity: Entity {
|
||||||
|
shape: sql_table
|
||||||
|
id: u64 {constraint: primary_key}
|
||||||
|
name: String
|
||||||
|
faction_id: u64 {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
|
||||||
|
position: Position {
|
||||||
|
shape: sql_table
|
||||||
|
entity_id: u64 {constraint: foreign_key}
|
||||||
|
x: f32
|
||||||
|
y: f32
|
||||||
|
chunk_id: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
knowledge: KnowledgeEntry {
|
||||||
|
shape: sql_table
|
||||||
|
observer_id: u64 {constraint: foreign_key}
|
||||||
|
subject_id: u64 {constraint: foreign_key}
|
||||||
|
fact_type: FactType
|
||||||
|
confidence: f32
|
||||||
|
last_seen_tick: u64
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.id -> position.entity_id
|
||||||
|
entity.id -> knowledge.observer_id
|
||||||
|
entity.id -> knowledge.subject_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sequence / Data Flow
|
||||||
|
|
||||||
|
Ordered interactions between systems over time.
|
||||||
|
|
||||||
|
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
|
||||||
|
|
||||||
|
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
|
||||||
|
client: Godot Client
|
||||||
|
bridge: IPC Bridge
|
||||||
|
server: Rust Server
|
||||||
|
ecs: ECS Systems
|
||||||
|
|
||||||
|
client -> bridge: TickRequest(delta, input)
|
||||||
|
bridge -> server: deserialize + dispatch
|
||||||
|
server -> ecs: run_systems(delta)
|
||||||
|
ecs -> ecs: perception, AI, physics
|
||||||
|
ecs -> server: collect WorldState
|
||||||
|
server -> bridge: serialize WorldState
|
||||||
|
bridge -> client: WorldState(entities, events)
|
||||||
|
client -> client: update rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. State Machine
|
||||||
|
|
||||||
|
Entity states, transitions, conditions.
|
||||||
|
|
||||||
|
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
|
||||||
|
|
||||||
|
**Agents:** Tyre (behavior system design), Qatux (state documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-state: "#2a3040"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-edge: "#c8d8f0"
|
||||||
|
color-decision: "#333340"
|
||||||
|
}
|
||||||
|
|
||||||
|
idle: Idle {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert: Alert {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
investigate: Investigate {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
combat: Combat {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: "#f0b840"
|
||||||
|
}
|
||||||
|
|
||||||
|
flee: Flee {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
|
||||||
|
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
|
||||||
|
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
|
||||||
|
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
|
||||||
|
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI Flow
|
||||||
|
|
||||||
|
Screen navigation, component hierarchy, interaction paths.
|
||||||
|
|
||||||
|
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
|
||||||
|
|
||||||
|
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-screen: "#1a1e24"
|
||||||
|
color-panel: "#2a3040"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-nav: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
gameplay: Gameplay {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
hud: HUD {
|
||||||
|
style.fill: ${color-panel}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
minimap: Minimap
|
||||||
|
monologue: Monologue Panel
|
||||||
|
insert_display: Insert Display
|
||||||
|
}
|
||||||
|
|
||||||
|
world: World View {
|
||||||
|
style.fill: ${color-panel}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause: Pause Menu {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
inventory: Inventory
|
||||||
|
journal: Journal
|
||||||
|
settings: Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogue: Dialogue Mode {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
speaker: Speaker Panel
|
||||||
|
responses: Response List
|
||||||
|
}
|
||||||
|
|
||||||
|
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
|
||||||
|
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||||
|
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
|
||||||
|
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||||
|
```
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||||
|
RENDER="$SCRIPT_DIR/d2-render.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [directory] [options]
|
||||||
|
|
||||||
|
Batch render all .d2 files in a directory. Skips files whose PNG is
|
||||||
|
newer than the source unless --force is used.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run List files that would be rendered
|
||||||
|
--force Re-render even if SVG is up to date
|
||||||
|
--theme N Override theme for all files
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") # All in docs/diagrams/
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ # One category
|
||||||
|
$(basename "$0") --dry-run # Preview
|
||||||
|
$(basename "$0") --force # Re-render everything
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DIR="$REPO_ROOT/docs/diagrams"
|
||||||
|
DRY_RUN=false
|
||||||
|
FORCE=false
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--force) FORCE=true; shift ;;
|
||||||
|
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
*)
|
||||||
|
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
|
||||||
|
DIR="$1"
|
||||||
|
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
|
||||||
|
else
|
||||||
|
echo "Unknown option or directory: $1" >&2; exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
|
||||||
|
|
||||||
|
RENDERED=0
|
||||||
|
SKIPPED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' d2_file; do
|
||||||
|
png_file="${d2_file%.d2}.png"
|
||||||
|
|
||||||
|
# Skip if PNG is newer than source (unless --force)
|
||||||
|
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rel_path="${d2_file#"$REPO_ROOT/"}"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo "Would render: $rel_path"
|
||||||
|
RENDERED=$((RENDERED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
|
||||||
|
RENDERED=$((RENDERED + 1))
|
||||||
|
else
|
||||||
|
echo "FAILED: $rel_path" >&2
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
D2="/home/linuxbrew/.linuxbrew/bin/d2"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
DEFAULT_THEME=200
|
||||||
|
DEFAULT_LAYOUT="dagre"
|
||||||
|
DEFAULT_PAD=100
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
|
||||||
|
|
||||||
|
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
validate <file> Check syntax without rendering
|
||||||
|
fmt <file> Auto-format in place
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--theme N Override theme (default: $DEFAULT_THEME)
|
||||||
|
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
|
||||||
|
--sketch Enable hand-drawn sketch mode
|
||||||
|
--output PATH Override output path (default: input with .png extension)
|
||||||
|
--svg Render to SVG instead of PNG
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
# Parse subcommand
|
||||||
|
SUBCMD=""
|
||||||
|
case "$1" in
|
||||||
|
validate|fmt)
|
||||||
|
SUBCMD="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
INPUT="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
# Resolve to absolute path
|
||||||
|
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
|
||||||
|
|
||||||
|
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Handle subcommands
|
||||||
|
if [[ -n "$SUBCMD" ]]; then
|
||||||
|
"$D2" "$SUBCMD" "$INPUT"
|
||||||
|
echo "OK: $SUBCMD $INPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse render options
|
||||||
|
THEME="$DEFAULT_THEME"
|
||||||
|
LAYOUT="$DEFAULT_LAYOUT"
|
||||||
|
SKETCH=""
|
||||||
|
OUTPUT=""
|
||||||
|
FORMAT="png"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--theme) THEME="$2"; shift 2 ;;
|
||||||
|
--layout) LAYOUT="$2"; shift 2 ;;
|
||||||
|
--sketch) SKETCH="-s"; shift ;;
|
||||||
|
--output) OUTPUT="$2"; shift 2 ;;
|
||||||
|
--svg) FORMAT="svg"; shift ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Derive output path
|
||||||
|
if [[ -z "$OUTPUT" ]]; then
|
||||||
|
OUTPUT="${INPUT%.d2}.$FORMAT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render
|
||||||
|
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
|
||||||
|
|
||||||
|
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
|
||||||
|
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
name: frame0-wireframe
|
||||||
|
description: >
|
||||||
|
Create and export UI wireframes using Frame0 (local desktop wireframing
|
||||||
|
app with HTTP API). Use when the user says "create wireframe", "wireframe
|
||||||
|
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
|
||||||
|
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
|
||||||
|
Wireframes are authored as local JSON files (source of truth) and synced
|
||||||
|
to Frame0 for rendering and export. Requires Frame0 to be running locally.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frame0 Wireframe Generation
|
||||||
|
|
||||||
|
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
|
||||||
|
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
|
||||||
|
|
||||||
|
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
|
||||||
|
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
|
||||||
|
the user explicitly says they have made edits in Frame0 and want to import
|
||||||
|
them. The pull workflow exists for that case only — do not use it proactively.
|
||||||
|
|
||||||
|
**Prerequisite:** Frame0 desktop app must be running. If not available,
|
||||||
|
stop and inform the user. Point to `references/setup-guide.md`.
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
Always check first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
Wireframing is a **design conversation, not a batch job.** Produce **one screen
|
||||||
|
at a time**, show it, and **stop for the user's reaction** before authoring the
|
||||||
|
next. Each shown screen is a checkpoint — never fan out a whole set of
|
||||||
|
wireframes unprompted, even if the user described several screens up front.
|
||||||
|
|
||||||
|
1. **Health check** — verify Frame0 is running
|
||||||
|
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`.
|
||||||
|
Author **one** screen.
|
||||||
|
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
|
||||||
|
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
|
||||||
|
5. **Show it inline** — surface the exported PNG in the conversation so the
|
||||||
|
user sees the result without opening Frame0. Always do this after an export,
|
||||||
|
and again whenever you re-export or the user asks to see it.
|
||||||
|
- **Inside clide** (the IDE hosting this session — `CLIDE_SOCK` is set):
|
||||||
|
render it as a native image card in the live Claude pane with
|
||||||
|
`clide image show <path> [--caption "…"]` (T-249). This is the *proper*
|
||||||
|
way to put an image in the clide conversation — a bare `Read` only shows
|
||||||
|
it in the transcript, not in the UI the user is looking at.
|
||||||
|
- **Otherwise**: `Read` the PNG so it renders inline in the transcript.
|
||||||
|
6. **STOP and wait for approval** — after showing a screen, pause and ask the
|
||||||
|
user for feedback. Do **not** author, push, or export the next screen until
|
||||||
|
they give an explicit go-ahead. Iterate on the current screen until they're
|
||||||
|
happy, then proceed to the next one — looping back to step 2 for each.
|
||||||
|
7. **Clean up** — delete test/scratch pages from Frame0 when done
|
||||||
|
|
||||||
|
> **Multi-screen requests are still one-at-a-time.** If the user asks for
|
||||||
|
> several screens, treat it as a queue: build the first, show it, get approval,
|
||||||
|
> then move on. The only exception is an explicit, unambiguous instruction to
|
||||||
|
> generate a batch without stopping (and even then prefer the batch-export
|
||||||
|
> dry-run + approval flow below).
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
|
||||||
|
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
|
||||||
|
|
||||||
|
## Wireframe JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Dialogue Box",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||||
|
"fillColor": "#1a1e24",
|
||||||
|
"strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"speaker": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 520,
|
||||||
|
"text": "LERA KONSTANTIN",
|
||||||
|
"fontColor": "#c8d0e0",
|
||||||
|
"fontSize": 16
|
||||||
|
},
|
||||||
|
"btn-ask": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040",
|
||||||
|
"strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectors": {
|
||||||
|
"flow-1": {
|
||||||
|
"tailId": "panel",
|
||||||
|
"headId": "btn-ask",
|
||||||
|
"strokeColor": "#c8d8f0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key rules
|
||||||
|
|
||||||
|
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
|
||||||
|
- **`parent`** references another local shape ID for nesting
|
||||||
|
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
|
||||||
|
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
|
||||||
|
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
|
||||||
|
theme tokens for colors). The sync script handles the mapping transparently.
|
||||||
|
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
|
||||||
|
|
||||||
|
### Sync commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
|
||||||
|
|
||||||
|
# Push local JSON to Frame0 (clears page, recreates all shapes)
|
||||||
|
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
|
||||||
|
|
||||||
|
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
|
||||||
|
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
|
||||||
|
|
||||||
|
# Push + export as PNG in one step
|
||||||
|
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
|
||||||
|
docs/design/wireframes/dialogue/dialogue-box.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch export
|
||||||
|
|
||||||
|
Use this when exporting multiple wireframes. It runs as a single Bash call,
|
||||||
|
avoiding repeated permission prompts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
|
||||||
|
|
||||||
|
# Dry run first — shows full manifest, no Frame0 calls
|
||||||
|
$BATCH --dry-run
|
||||||
|
|
||||||
|
# Export everything (skips PNGs already newer than their JSON)
|
||||||
|
$BATCH
|
||||||
|
|
||||||
|
# Export one category only
|
||||||
|
$BATCH --category dialogue
|
||||||
|
|
||||||
|
# Force re-export of everything
|
||||||
|
$BATCH --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always dry-run first, then get approval before running the live export.**
|
||||||
|
|
||||||
|
## Low-Level Commands
|
||||||
|
|
||||||
|
For ad-hoc operations or exec calls not covered by sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
|
||||||
|
$CMD health
|
||||||
|
$CMD list-pages
|
||||||
|
$CMD current-page
|
||||||
|
$CMD get-page <page-id>
|
||||||
|
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||||
|
$CMD create-connector <tail-id> <head-id>
|
||||||
|
$CMD move <shape-id> <dx> <dy>
|
||||||
|
$CMD export --format image/png
|
||||||
|
$CMD exec "view:fit-to-screen" '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If you find yourself using `exec` for the same command repeatedly, flag it as
|
||||||
|
a candidate for a proper wrapper in `frame0-cmd.sh`.
|
||||||
|
|
||||||
|
## Project Styling Defaults
|
||||||
|
|
||||||
|
Colors from `docs/design/visual-grammar-v01.md`:
|
||||||
|
|
||||||
|
| Role | Hex | Frame0 token |
|
||||||
|
|------|-----|-------------|
|
||||||
|
| Background | `#1a1e24` | `$sage3` |
|
||||||
|
| Stroke | `#333340` | `$slate6` |
|
||||||
|
| Fill | `#2a3040` | `$slate5` |
|
||||||
|
| Text | `#c8d0e0` | `$mint12` |
|
||||||
|
| Accent | `#c8d8f0` | `$blue12` |
|
||||||
|
|
||||||
|
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
|
||||||
|
|
||||||
|
## Output Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/design/wireframes/
|
||||||
|
hud/ # HUD layout wireframes
|
||||||
|
menus/ # Menu screen wireframes
|
||||||
|
dialogue/ # Dialogue box wireframes
|
||||||
|
insert/ # Neural insert wireframes
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `.json` source and `.png` exports are tracked in git.
|
||||||
|
`.idmap.json` mapping files are gitignored.
|
||||||
|
|
||||||
|
## Agent Guidance
|
||||||
|
|
||||||
|
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
|
||||||
|
visual consistency. Uses all component library patterns.
|
||||||
|
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
|
||||||
|
- **Qatux** — Export wireframes for UI decision records and documentation.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `references/component-library.md` — Pre-built JSON wireframe templates
|
||||||
|
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
|
||||||
|
- `references/api-reference.md` — Frame0 HTTP API command reference and
|
||||||
|
type mappings. Read for low-level control.
|
||||||
|
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Frame0 HTTP API Reference
|
||||||
|
|
||||||
|
Frame0 exposes a local HTTP API when the desktop app is running.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:{port}/execute_command
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
|
||||||
|
|
||||||
|
## Request / Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "namespace:action", "args": { ... }}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "data": { ... }}
|
||||||
|
{"success": false, "error": "description"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Mapping
|
||||||
|
|
||||||
|
Frame0 uses different type names for create vs get:
|
||||||
|
|
||||||
|
| Create API (`type`) | Get API (internal) | Description |
|
||||||
|
|--------------------|--------------------|-------------|
|
||||||
|
| `Rectangle` | `Box` | Rectangle with optional corners |
|
||||||
|
| `Ellipse` | `Ellipse` | Circle/ellipse |
|
||||||
|
| `Text` | `Text` | Text label |
|
||||||
|
| `Line` | `Line` | Line/polyline |
|
||||||
|
| `Frame` | `Frame` | Container from library |
|
||||||
|
| `Freehand` | `Freehand` | Freehand drawing |
|
||||||
|
| `Highlighter` | `Highlighter` | Highlighter stroke |
|
||||||
|
|
||||||
|
The sync script handles this mapping transparently.
|
||||||
|
|
||||||
|
## Color Tokens
|
||||||
|
|
||||||
|
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
|
||||||
|
|
||||||
|
| Hex | Token | Role |
|
||||||
|
|-----|-------|------|
|
||||||
|
| `#1a1e24` | `$sage3` | Background |
|
||||||
|
| `#2a3040` | `$slate5` | Fill |
|
||||||
|
| `#333340` | `$slate6` | Stroke |
|
||||||
|
| `#c8d0e0` | `$mint12` | Text |
|
||||||
|
| `#c8d8f0` | `$blue12` | Accent |
|
||||||
|
|
||||||
|
Both hex and token strings work in the API. Tokens are preserved on round-trip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### shape:create-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-shape",
|
||||||
|
"args": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"shapeProps": {
|
||||||
|
"name": "my-button",
|
||||||
|
"left": 100, "top": 200, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040",
|
||||||
|
"strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"parentId": "optional-parent-shape-id",
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: shape ID (string).
|
||||||
|
|
||||||
|
### shape:get-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:update-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:update-shape",
|
||||||
|
"args": {
|
||||||
|
"shapeId": "id",
|
||||||
|
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:move
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:create-connector
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-connector",
|
||||||
|
"args": {
|
||||||
|
"tailId": "source-id",
|
||||||
|
"headId": "target-id",
|
||||||
|
"shapeProps": {"strokeColor": "#c8d8f0"},
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:create-icon
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-icon",
|
||||||
|
"args": {
|
||||||
|
"iconName": "search",
|
||||||
|
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:get-available-icons
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:get-available-icons", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:group / shape:ungroup
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||||
|
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### edit:delete / edit:duplicate
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||||
|
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:add
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `{id, type, name}`.
|
||||||
|
|
||||||
|
### page:get
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:get-current-page
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:get-current-page", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: page ID string.
|
||||||
|
|
||||||
|
### page:set-current-page
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:set-current-page", "args": {"pageId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### doc:get (list all pages)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:delete
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:delete", "args": {"pageId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### file:export-image
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "file:export-image",
|
||||||
|
"args": {
|
||||||
|
"pageId": "optional-page-id",
|
||||||
|
"format": "image/png",
|
||||||
|
"fillBackground": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
|
||||||
|
Returns: base64-encoded image data.
|
||||||
|
|
||||||
|
### view:fit-to-screen
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "view:fit-to-screen", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shape Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| `name` | string | Shape identifier/label |
|
||||||
|
| `left` | number | X position (origin: top-left) |
|
||||||
|
| `top` | number | Y position |
|
||||||
|
| `width` | number | Width in pixels |
|
||||||
|
| `height` | number | Height in pixels |
|
||||||
|
| `fillColor` | string | Hex or `$token` |
|
||||||
|
| `strokeColor` | string | Hex or `$token` |
|
||||||
|
| `strokeWidth` | number | Border width |
|
||||||
|
| `fontColor` | string | Text color (hex or `$token`) |
|
||||||
|
| `fontSize` | number | Font size in pixels |
|
||||||
|
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
|
||||||
|
| `text` | string | Text content |
|
||||||
|
| `wordWrap` | boolean | Enable word wrapping |
|
||||||
|
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
|
||||||
|
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
|
||||||
|
| `constraints` | array | Auto-sizing constraints |
|
||||||
|
| `horzAlign` | string | Horizontal text alignment |
|
||||||
|
| `vertAlign` | string | Vertical text alignment |
|
||||||
|
| `fillStyle` | string | Fill style (`none` for transparent) |
|
||||||
|
| `path` | array | Coordinate pairs for lines |
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Component Library
|
||||||
|
|
||||||
|
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
|
||||||
|
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
|
||||||
|
|
||||||
|
**Viewport:** 1140x780 (Godot project settings)
|
||||||
|
**Grid unit:** 8px
|
||||||
|
**Min touch target:** 36px height
|
||||||
|
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. HUD Layout
|
||||||
|
|
||||||
|
Main gameplay overlay. Minimap top-right, monologue bottom-center,
|
||||||
|
insert display bottom-left, action hints bottom-right.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "HUD Layout",
|
||||||
|
"shapes": {
|
||||||
|
"minimap": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 880, "top": 20, "width": 240, "height": 240,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"minimap-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "minimap",
|
||||||
|
"left": 890, "top": 30,
|
||||||
|
"text": "Minimap",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"monologue": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 300, "top": 680, "width": 520, "height": 80,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"monologue-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "monologue",
|
||||||
|
"left": 310, "top": 700, "width": 500,
|
||||||
|
"text": "Internal monologue text appears here...",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
|
||||||
|
},
|
||||||
|
"insert": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 20, "top": 600, "width": 260, "height": 160,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"insert-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "insert",
|
||||||
|
"left": 30, "top": 620,
|
||||||
|
"text": "Neural Insert Data",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 880, "top": 700, "width": 240, "height": 60,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"hints-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "hints",
|
||||||
|
"left": 890, "top": 720,
|
||||||
|
"text": "[E] Interact [TAB] Insert",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dialogue Box
|
||||||
|
|
||||||
|
Speaker panel with response options. Anchored bottom-center during dialogue mode.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Dialogue Box",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"speaker": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 520,
|
||||||
|
"text": "LERA KONSTANTIN",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"text-area": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 550, "width": 760, "height": 100,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"dialogue-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "text-area",
|
||||||
|
"left": 200, "top": 560, "width": 740,
|
||||||
|
"text": "You look like you could use a drink. First time on the station?",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
},
|
||||||
|
"btn-option1": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-option1-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-option1",
|
||||||
|
"left": 200, "top": 674,
|
||||||
|
"text": "[1] Ask about the station",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
},
|
||||||
|
"btn-option2": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 710, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-option2-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-option2",
|
||||||
|
"left": 200, "top": 714,
|
||||||
|
"text": "[2] Ask about recent events",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
},
|
||||||
|
"btn-leave": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 580, "top": 670, "width": 180, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-leave-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-leave",
|
||||||
|
"left": 590, "top": 674,
|
||||||
|
"text": "[3] Leave",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Menu Screen
|
||||||
|
|
||||||
|
Full-screen menu with sidebar navigation and content area.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Pause Menu",
|
||||||
|
"shapes": {
|
||||||
|
"bg": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||||
|
"fillColor": "#1a1e24"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "bg",
|
||||||
|
"left": 20, "top": 20, "width": 200, "height": 740,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-inventory": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 40, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-inventory-label": {
|
||||||
|
"type": "Text", "parent": "btn-inventory",
|
||||||
|
"left": 40, "top": 48, "text": "Inventory",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-journal": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 86, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-journal-label": {
|
||||||
|
"type": "Text", "parent": "btn-journal",
|
||||||
|
"left": 40, "top": 94, "text": "Journal",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-map": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 132, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-map-label": {
|
||||||
|
"type": "Text", "parent": "btn-map",
|
||||||
|
"left": 40, "top": 140, "text": "Map",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-settings": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 178, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-settings-label": {
|
||||||
|
"type": "Text", "parent": "btn-settings",
|
||||||
|
"left": 40, "top": 186, "text": "Settings",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-resume": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 720, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-resume-label": {
|
||||||
|
"type": "Text", "parent": "btn-resume",
|
||||||
|
"left": 40, "top": 728, "text": "Resume",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "bg",
|
||||||
|
"left": 240, "top": 20, "width": 880, "height": 740,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"content-label": {
|
||||||
|
"type": "Text", "parent": "content",
|
||||||
|
"left": 260, "top": 40,
|
||||||
|
"text": "Content area",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modal Dialog
|
||||||
|
|
||||||
|
Centered overlay for confirmations, alerts, choices.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Modal Dialog",
|
||||||
|
"shapes": {
|
||||||
|
"overlay": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||||
|
"fillColor": "#0a0c10"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "overlay",
|
||||||
|
"left": 320, "top": 240, "width": 500, "height": 300,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 260,
|
||||||
|
"text": "Confirm Action",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"type": "Line", "parent": "modal",
|
||||||
|
"left": 340, "top": 290, "width": 460, "height": 0,
|
||||||
|
"strokeColor": "#333340"
|
||||||
|
},
|
||||||
|
"body-1": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 310,
|
||||||
|
"text": "Are you sure you want to proceed?",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"body-2": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 340,
|
||||||
|
"text": "This action cannot be undone.",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-cancel": {
|
||||||
|
"type": "Rectangle", "parent": "modal",
|
||||||
|
"left": 480, "top": 480, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-cancel-label": {
|
||||||
|
"type": "Text", "parent": "btn-cancel",
|
||||||
|
"left": 510, "top": 488,
|
||||||
|
"text": "Cancel",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-confirm": {
|
||||||
|
"type": "Rectangle", "parent": "modal",
|
||||||
|
"left": 620, "top": 480, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-confirm-label": {
|
||||||
|
"type": "Text", "parent": "btn-confirm",
|
||||||
|
"left": 645, "top": 488,
|
||||||
|
"text": "Confirm",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. List View
|
||||||
|
|
||||||
|
Scrollable list with item selection and detail panel.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "List View",
|
||||||
|
"shapes": {
|
||||||
|
"list-panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 20, "top": 20, "width": 400, "height": 740,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-1": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 30, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-1-label": {
|
||||||
|
"type": "Text", "parent": "item-1",
|
||||||
|
"left": 40, "top": 38, "text": "Item Alpha",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"item-2": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 80, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-2-label": {
|
||||||
|
"type": "Text", "parent": "item-2",
|
||||||
|
"left": 40, "top": 88, "text": "Item Beta",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"item-3": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 130, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-3-label": {
|
||||||
|
"type": "Text", "parent": "item-3",
|
||||||
|
"left": 40, "top": 138, "text": "Item Gamma",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"detail-panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 440, "top": 20, "width": 680, "height": 740,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail-title": {
|
||||||
|
"type": "Text", "parent": "detail-panel",
|
||||||
|
"left": 460, "top": 40,
|
||||||
|
"text": "Item Alpha",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"detail-body": {
|
||||||
|
"type": "Text", "parent": "detail-panel",
|
||||||
|
"left": 460, "top": 80, "width": 640,
|
||||||
|
"text": "Description and properties appear here.",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Inventory Grid
|
||||||
|
|
||||||
|
Grid of cells for item management.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Inventory Grid",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 240, "top": 100, "width": 660, "height": 580,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "Text", "parent": "panel",
|
||||||
|
"left": 260, "top": 120,
|
||||||
|
"text": "INVENTORY",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"cell-1-1": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 260, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-2": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 332, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-3": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 404, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-4": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 476, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-1": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 260, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-2": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 332, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-3": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 404, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-4": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 476, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 580, "top": 160, "width": 300, "height": 400,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail-title": {
|
||||||
|
"type": "Text", "parent": "detail",
|
||||||
|
"left": 600, "top": 180,
|
||||||
|
"text": "Selected Item Name",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"detail-body": {
|
||||||
|
"type": "Text", "parent": "detail",
|
||||||
|
"left": 600, "top": 210, "width": 260,
|
||||||
|
"text": "Item description and stats",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Frame0 Setup Guide
|
||||||
|
|
||||||
|
## Installation (Fedora)
|
||||||
|
|
||||||
|
Download from https://frame0.app/download and install the RPM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install ./frame0-*.x86_64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires: Fedora 40 or later (x86_64).
|
||||||
|
|
||||||
|
## Starting Frame0
|
||||||
|
|
||||||
|
Launch the desktop application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
frame0 &
|
||||||
|
```
|
||||||
|
|
||||||
|
Frame0 exposes an HTTP API at `localhost:58320` when running.
|
||||||
|
|
||||||
|
## Verify API Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `Frame0 is running on port 58320`
|
||||||
|
|
||||||
|
## Port Configuration
|
||||||
|
|
||||||
|
Default port: **58320**
|
||||||
|
|
||||||
|
To use a different port, set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FRAME0_PORT=58321
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pass `--port` to any script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| "Connection refused" | Frame0 not running | Start the desktop app |
|
||||||
|
| "Port in use" | Another instance running | Close duplicate or use different port |
|
||||||
|
| Script hangs | API unresponsive | Restart Frame0 |
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${FRAME0_PORT:-58320}"
|
||||||
|
ENDPOINT="http://localhost:${PORT}/execute_command"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") <command> [args...] [--port N]
|
||||||
|
|
||||||
|
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
|
||||||
|
curl calls. Requires Frame0 desktop app to be running.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
health Check if Frame0 is running
|
||||||
|
exec <namespace:action> <json> Execute a raw API command
|
||||||
|
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
|
||||||
|
get-shape <id> Get shape details
|
||||||
|
update-shape <id> <json-props> Update shape properties
|
||||||
|
delete <id> [id...] Delete shapes by ID
|
||||||
|
move <id> <dx> <dy> Move a shape by pixel offset
|
||||||
|
duplicate <id> Duplicate a shape
|
||||||
|
group <id> [id...] Group shapes
|
||||||
|
ungroup <group-id> Ungroup a group
|
||||||
|
create-connector <tail-id> <head-id> [json-props] Connect two shapes
|
||||||
|
create-icon <name> <json-props> Create an icon shape
|
||||||
|
add-page <name> Add a new page (becomes current)
|
||||||
|
get-page [page-id] Get current or specific page data
|
||||||
|
list-pages [--shapes] List all pages (--shapes for shape data)
|
||||||
|
current-page Get current page ID
|
||||||
|
set-page <page-id> Set current page
|
||||||
|
export [page-id] [--format mime] Export page as image (default: image/png)
|
||||||
|
fit Fit view to screen
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") health
|
||||||
|
$(basename "$0") add-page "HUD Layout"
|
||||||
|
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||||
|
$(basename "$0") list-pages
|
||||||
|
$(basename "$0") export --format image/png
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse --port from anywhere in args
|
||||||
|
ARGS=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
# Execute a Frame0 API command, return data or error
|
||||||
|
frame0_exec() {
|
||||||
|
local command="$1"
|
||||||
|
local args
|
||||||
|
args="${2:-"{}"}"
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
|
||||||
|
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
|
||||||
|
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
local http_code body
|
||||||
|
http_code=$(echo "$response" | tail -1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" != 2* ]]; then
|
||||||
|
echo "ERROR: HTTP $http_code from Frame0" >&2
|
||||||
|
echo "$body" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse success/error from response
|
||||||
|
python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
r = json.loads(sys.stdin.read())
|
||||||
|
if r.get('success'):
|
||||||
|
d = r.get('data')
|
||||||
|
if d is not None:
|
||||||
|
print(json.dumps(d, indent=2))
|
||||||
|
else:
|
||||||
|
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
" <<< "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build JSON array from remaining args
|
||||||
|
ids_to_json_array() {
|
||||||
|
local arr="["
|
||||||
|
local first=true
|
||||||
|
for id in "$@"; do
|
||||||
|
[[ "$first" == true ]] && first=false || arr+=","
|
||||||
|
arr+="\"$id\""
|
||||||
|
done
|
||||||
|
arr+="]"
|
||||||
|
echo "$arr"
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
health)
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
|
||||||
|
echo "Frame0 is running on port $PORT"
|
||||||
|
else
|
||||||
|
echo "Frame0 is NOT running on port $PORT" >&2
|
||||||
|
echo "Start Frame0 desktop app, then retry." >&2
|
||||||
|
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
exec)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
|
||||||
|
frame0_exec "$1" "$2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-shape)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
|
||||||
|
local_type="$1"
|
||||||
|
local_props="$2"
|
||||||
|
local_parent="${3:-}"
|
||||||
|
local_parent_arg=""
|
||||||
|
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
|
||||||
|
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
get-shape)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
update-shape)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
delete)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
|
||||||
|
local_arr=$(ids_to_json_array "$@")
|
||||||
|
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
move)
|
||||||
|
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
duplicate)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
|
||||||
|
local_dx="${2:-0}"
|
||||||
|
local_dy="${3:-0}"
|
||||||
|
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
group)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
|
||||||
|
local_arr=$(ids_to_json_array "$@")
|
||||||
|
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
ungroup)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-connector)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
|
||||||
|
local_props="${3:-{}}"
|
||||||
|
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-icon)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
add-page)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
|
||||||
|
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
get-page)
|
||||||
|
if [[ $# -ge 1 ]]; then
|
||||||
|
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
|
||||||
|
else
|
||||||
|
local_id
|
||||||
|
local_id=$(frame0_exec "page:get-current-page")
|
||||||
|
# Strip quotes from returned ID
|
||||||
|
local_id=$(echo "$local_id" | tr -d '"')
|
||||||
|
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
list-pages)
|
||||||
|
local_shapes="false"
|
||||||
|
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
|
||||||
|
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
current-page)
|
||||||
|
frame0_exec "page:get-current-page"
|
||||||
|
;;
|
||||||
|
|
||||||
|
set-page)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
|
||||||
|
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
export)
|
||||||
|
local_page_id=""
|
||||||
|
local_format="image/png"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--format) local_format="$2"; shift 2 ;;
|
||||||
|
*) local_page_id="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
local_page_arg=""
|
||||||
|
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
|
||||||
|
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
fit)
|
||||||
|
frame0_exec "view:fit-to-screen"
|
||||||
|
;;
|
||||||
|
|
||||||
|
--help|-h|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $CMD" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Batch export wireframes from JSON to PNG via Frame0.
|
||||||
|
#
|
||||||
|
# Finds all .json wireframe files under docs/design/wireframes/ and exports
|
||||||
|
# each to a matching .png. Skips files whose PNG is already newer than the
|
||||||
|
# JSON, unless --force is passed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --dry-run Print manifest only, don't touch Frame0.
|
||||||
|
# --force Re-export even if PNG already exists and is up to date.
|
||||||
|
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
|
||||||
|
# --root DIR Wireframes root dir (default: docs/design/wireframes)
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 All exports succeeded (or nothing to do)
|
||||||
|
# 1 One or more exports failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||||
|
SYNC="$SCRIPT_DIR/frame0-sync.py"
|
||||||
|
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
FORCE=false
|
||||||
|
CATEGORY=""
|
||||||
|
WF_ROOT="$DEFAULT_ROOT"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--force) FORCE=true; shift ;;
|
||||||
|
--category) CATEGORY="$2"; shift 2 ;;
|
||||||
|
--root) WF_ROOT="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '/^# /p' "$0" | sed 's/^# //'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -d "$WF_ROOT" ]]; then
|
||||||
|
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect JSON files, optionally filtered by category subdirectory
|
||||||
|
mapfile -t JSON_FILES < <(
|
||||||
|
if [[ -n "$CATEGORY" ]]; then
|
||||||
|
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
|
||||||
|
else
|
||||||
|
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
|
||||||
|
echo "No wireframe JSON files found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Classify files into to-export and to-skip
|
||||||
|
TO_EXPORT=()
|
||||||
|
TO_SKIP=()
|
||||||
|
|
||||||
|
for json in "${JSON_FILES[@]}"; do
|
||||||
|
png="${json%.json}.png"
|
||||||
|
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
|
||||||
|
TO_EXPORT+=("$json")
|
||||||
|
else
|
||||||
|
TO_SKIP+=("$json")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Print manifest
|
||||||
|
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Will export (${#TO_EXPORT[@]} files):"
|
||||||
|
for json in "${TO_EXPORT[@]}"; do
|
||||||
|
png="${json%.json}.png"
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
if [[ ! -f "$png" ]]; then
|
||||||
|
status="new"
|
||||||
|
else
|
||||||
|
status="updated"
|
||||||
|
fi
|
||||||
|
printf " [%-7s] %s\n" "$status" "$rel"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Nothing to export (all PNGs up to date)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
|
||||||
|
for json in "${TO_SKIP[@]}"; do
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
printf " [skip ] %s\n" "$rel"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo ""
|
||||||
|
echo "Dry run — no exports performed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
PASSED=0
|
||||||
|
FAILED=0
|
||||||
|
FAILED_FILES=()
|
||||||
|
|
||||||
|
TOTAL=${#TO_EXPORT[@]}
|
||||||
|
IDX=0
|
||||||
|
|
||||||
|
for json in "${TO_EXPORT[@]}"; do
|
||||||
|
IDX=$((IDX + 1))
|
||||||
|
png="${json%.json}.png"
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
|
||||||
|
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
|
||||||
|
|
||||||
|
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
|
||||||
|
rc=$?
|
||||||
|
if [[ $rc -eq 0 ]]; then
|
||||||
|
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
|
||||||
|
echo "ok $size"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
else
|
||||||
|
echo "FAILED"
|
||||||
|
cat /tmp/frame0-batch-err.txt >&2
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
FAILED_FILES+=("$rel")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "$PASSED exported, $FAILED failed."
|
||||||
|
|
||||||
|
if [[ $FAILED -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Failed:" >&2
|
||||||
|
for f in "${FAILED_FILES[@]}"; do
|
||||||
|
echo " $f" >&2
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
|
||||||
|
|
||||||
|
Local JSON is source of truth. Frame0 is a renderer.
|
||||||
|
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
frame0-sync.py push <wireframe.json> [--port PORT]
|
||||||
|
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
|
||||||
|
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
DEFAULT_PORT = 58320
|
||||||
|
|
||||||
|
|
||||||
|
def api(port, command, args=None):
|
||||||
|
"""Execute a Frame0 API command. Returns the data field on success."""
|
||||||
|
url = f"http://localhost:{port}/execute_command"
|
||||||
|
payload = json.dumps({"command": command, "args": args or {}}).encode()
|
||||||
|
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Mapping file management --------------------------------------------------
|
||||||
|
|
||||||
|
def mapping_path(wireframe_path):
|
||||||
|
"""Derive the idmap path from the wireframe JSON path."""
|
||||||
|
d = os.path.dirname(wireframe_path)
|
||||||
|
base = os.path.splitext(os.path.basename(wireframe_path))[0]
|
||||||
|
return os.path.join(d, f".{base}.idmap.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_mapping(wireframe_path):
|
||||||
|
p = mapping_path(wireframe_path)
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {"page_id": None, "shapes": {}, "connectors": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_mapping(wireframe_path, mapping):
|
||||||
|
p = mapping_path(wireframe_path)
|
||||||
|
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump(mapping, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Push: local JSON -> Frame0 -----------------------------------------------
|
||||||
|
|
||||||
|
# Frame0 returns different type names from get vs what create accepts.
|
||||||
|
# Map internal types back to create-API types.
|
||||||
|
TYPE_TO_CREATE = {
|
||||||
|
"Box": "Rectangle",
|
||||||
|
"box": "Rectangle",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def topo_sort_shapes(shapes):
|
||||||
|
"""Sort shape IDs so parents come before children."""
|
||||||
|
order = []
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def visit(sid):
|
||||||
|
if sid in visited:
|
||||||
|
return
|
||||||
|
visited.add(sid)
|
||||||
|
shape = shapes[sid]
|
||||||
|
parent = shape.get("parent")
|
||||||
|
if parent and parent in shapes:
|
||||||
|
visit(parent)
|
||||||
|
order.append(sid)
|
||||||
|
|
||||||
|
for sid in shapes:
|
||||||
|
visit(sid)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_page(port, name, mapping):
|
||||||
|
"""Find existing page by mapping or name, or create a new one."""
|
||||||
|
# Try mapped page_id first
|
||||||
|
if mapping.get("page_id"):
|
||||||
|
try:
|
||||||
|
page = api(port, "page:get", {"pageId": mapping["page_id"]})
|
||||||
|
if page:
|
||||||
|
return mapping["page_id"]
|
||||||
|
except SystemExit:
|
||||||
|
pass # Page no longer exists, fall through
|
||||||
|
|
||||||
|
# Search by name in existing pages
|
||||||
|
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||||
|
if doc and "children" in doc:
|
||||||
|
for page in doc["children"]:
|
||||||
|
if page.get("name") == name:
|
||||||
|
return page["id"]
|
||||||
|
|
||||||
|
# Create new page
|
||||||
|
page = api(port, "page:add", {"pageProps": {"name": name}})
|
||||||
|
return page["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def clear_page(port, page_id):
|
||||||
|
"""Delete all shapes on a page."""
|
||||||
|
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||||
|
if not page or "children" not in page:
|
||||||
|
return
|
||||||
|
shape_ids = [s["id"] for s in page["children"]]
|
||||||
|
if shape_ids:
|
||||||
|
api(port, "edit:delete", {"shapeIdArray": shape_ids})
|
||||||
|
|
||||||
|
|
||||||
|
def push(wireframe_path, port):
|
||||||
|
"""Push local wireframe JSON to Frame0."""
|
||||||
|
with open(wireframe_path) as f:
|
||||||
|
wireframe = json.load(f)
|
||||||
|
|
||||||
|
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
|
||||||
|
shapes = wireframe.get("shapes", {})
|
||||||
|
connectors = wireframe.get("connectors", {})
|
||||||
|
|
||||||
|
mapping = load_mapping(wireframe_path)
|
||||||
|
|
||||||
|
# Find or create page
|
||||||
|
page_id = find_or_create_page(port, name, mapping)
|
||||||
|
mapping["page_id"] = page_id
|
||||||
|
|
||||||
|
# Switch to page and clear it
|
||||||
|
api(port, "page:set-current-page", {"pageId": page_id})
|
||||||
|
clear_page(port, page_id)
|
||||||
|
|
||||||
|
# Reset ID mappings (shapes are recreated)
|
||||||
|
mapping["shapes"] = {}
|
||||||
|
mapping["connectors"] = {}
|
||||||
|
|
||||||
|
# Create shapes in dependency order
|
||||||
|
order = topo_sort_shapes(shapes)
|
||||||
|
for local_id in order:
|
||||||
|
shape = shapes[local_id]
|
||||||
|
raw_type = shape.get("type", "Rectangle")
|
||||||
|
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
|
||||||
|
parent_local = shape.get("parent")
|
||||||
|
|
||||||
|
# Build shapeProps from everything except meta fields
|
||||||
|
meta_keys = {"type", "parent"}
|
||||||
|
props = {k: v for k, v in shape.items() if k not in meta_keys}
|
||||||
|
|
||||||
|
# Set name to local_id if not explicitly set
|
||||||
|
if "name" not in props:
|
||||||
|
props["name"] = local_id
|
||||||
|
|
||||||
|
create_args = {
|
||||||
|
"type": shape_type,
|
||||||
|
"shapeProps": props,
|
||||||
|
"convertColors": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve parent ID
|
||||||
|
if parent_local and parent_local in mapping["shapes"]:
|
||||||
|
create_args["parentId"] = mapping["shapes"][parent_local]
|
||||||
|
|
||||||
|
f0_id = api(port, "shape:create-shape", create_args)
|
||||||
|
mapping["shapes"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Create connectors
|
||||||
|
for local_id, conn in connectors.items():
|
||||||
|
tail_local = conn.get("tailId")
|
||||||
|
head_local = conn.get("headId")
|
||||||
|
|
||||||
|
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
|
||||||
|
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta_keys = {"tailId", "headId"}
|
||||||
|
props = {k: v for k, v in conn.items() if k not in meta_keys}
|
||||||
|
if "name" not in props:
|
||||||
|
props["name"] = local_id
|
||||||
|
|
||||||
|
f0_id = api(port, "shape:create-connector", {
|
||||||
|
"tailId": mapping["shapes"][tail_local],
|
||||||
|
"headId": mapping["shapes"][head_local],
|
||||||
|
"shapeProps": props,
|
||||||
|
"convertColors": True,
|
||||||
|
})
|
||||||
|
mapping["connectors"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Fit to screen
|
||||||
|
api(port, "view:fit-to-screen")
|
||||||
|
|
||||||
|
save_mapping(wireframe_path, mapping)
|
||||||
|
total = len(mapping["shapes"]) + len(mapping["connectors"])
|
||||||
|
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Pull: Frame0 -> local JSON -----------------------------------------------
|
||||||
|
|
||||||
|
def pull(page_ref, output_path, port):
|
||||||
|
"""Pull a Frame0 page into local wireframe JSON."""
|
||||||
|
# Resolve page_ref: could be an ID or a name
|
||||||
|
page_id = None
|
||||||
|
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||||
|
if doc and "children" in doc:
|
||||||
|
for page in doc["children"]:
|
||||||
|
if page["id"] == page_ref or page.get("name") == page_ref:
|
||||||
|
page_id = page["id"]
|
||||||
|
page_name = page.get("name", page_ref)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not page_id:
|
||||||
|
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load existing mapping for reverse lookup
|
||||||
|
mapping = load_mapping(output_path)
|
||||||
|
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
|
||||||
|
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
|
||||||
|
|
||||||
|
# Get full page with shapes
|
||||||
|
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||||
|
|
||||||
|
shapes = {}
|
||||||
|
connectors = {}
|
||||||
|
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
|
||||||
|
auto_id_counter = [0]
|
||||||
|
|
||||||
|
def auto_id(f0_shape):
|
||||||
|
"""Generate a stable local ID from shape name or auto-number."""
|
||||||
|
# Prefer existing mapping
|
||||||
|
f0_id = f0_shape["id"]
|
||||||
|
if f0_id in reverse_map:
|
||||||
|
return reverse_map[f0_id]
|
||||||
|
# Use sanitized name
|
||||||
|
name = f0_shape.get("name", "")
|
||||||
|
if name:
|
||||||
|
sanitized = name.lower().replace(" ", "-").replace("_", "-")
|
||||||
|
if sanitized not in shapes:
|
||||||
|
return sanitized
|
||||||
|
# Fallback: auto-number
|
||||||
|
auto_id_counter[0] += 1
|
||||||
|
return f"s{auto_id_counter[0]:03d}"
|
||||||
|
|
||||||
|
def process_shape(f0_shape, parent_local_id=None):
|
||||||
|
f0_id = f0_shape["id"]
|
||||||
|
local_id = auto_id(f0_shape)
|
||||||
|
new_mapping["shapes"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Extract shape properties — only strip structural keys that our
|
||||||
|
# ID mapping replaces. Everything else passes through as-is so the
|
||||||
|
# local JSON speaks Frame0's native vocabulary.
|
||||||
|
shape_type = f0_shape.get("type", "Box")
|
||||||
|
skip_keys = {"id", "type", "children", "pageId", "parentId"}
|
||||||
|
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
|
||||||
|
|
||||||
|
entry = {"type": shape_type}
|
||||||
|
if parent_local_id:
|
||||||
|
entry["parent"] = parent_local_id
|
||||||
|
entry.update(props)
|
||||||
|
|
||||||
|
# Remove name if it matches local_id (redundant)
|
||||||
|
if entry.get("name") == local_id:
|
||||||
|
del entry["name"]
|
||||||
|
|
||||||
|
shapes[local_id] = entry
|
||||||
|
|
||||||
|
# Process children recursively
|
||||||
|
for child in f0_shape.get("children", []):
|
||||||
|
child_type = child.get("type", "")
|
||||||
|
if child_type == "Connector":
|
||||||
|
process_connector(child)
|
||||||
|
else:
|
||||||
|
process_shape(child, local_id)
|
||||||
|
|
||||||
|
def process_connector(f0_conn):
|
||||||
|
f0_id = f0_conn["id"]
|
||||||
|
local_id = reverse_conn.get(f0_id)
|
||||||
|
if not local_id:
|
||||||
|
auto_id_counter[0] += 1
|
||||||
|
local_id = f"c{auto_id_counter[0]:03d}"
|
||||||
|
|
||||||
|
new_mapping["connectors"][local_id] = f0_id
|
||||||
|
|
||||||
|
tail_f0 = f0_conn.get("tail", {}).get("id")
|
||||||
|
head_f0 = f0_conn.get("head", {}).get("id")
|
||||||
|
|
||||||
|
entry = {}
|
||||||
|
if tail_f0:
|
||||||
|
# Will be resolved after all shapes are processed
|
||||||
|
entry["_tailF0"] = tail_f0
|
||||||
|
if head_f0:
|
||||||
|
entry["_headF0"] = head_f0
|
||||||
|
|
||||||
|
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
|
||||||
|
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
|
||||||
|
entry.update(props)
|
||||||
|
|
||||||
|
connectors[local_id] = entry
|
||||||
|
|
||||||
|
# Process all top-level shapes
|
||||||
|
for child in page.get("children", []):
|
||||||
|
child_type = child.get("type", "")
|
||||||
|
if child_type == "Connector":
|
||||||
|
process_connector(child)
|
||||||
|
else:
|
||||||
|
process_shape(child)
|
||||||
|
|
||||||
|
# Resolve connector references to local IDs
|
||||||
|
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
|
||||||
|
for conn in connectors.values():
|
||||||
|
tail_f0 = conn.pop("_tailF0", None)
|
||||||
|
head_f0 = conn.pop("_headF0", None)
|
||||||
|
if tail_f0 and tail_f0 in f0_to_local:
|
||||||
|
conn["tailId"] = f0_to_local[tail_f0]
|
||||||
|
if head_f0 and head_f0 in f0_to_local:
|
||||||
|
conn["headId"] = f0_to_local[head_f0]
|
||||||
|
|
||||||
|
wireframe = {"name": page_name}
|
||||||
|
if shapes:
|
||||||
|
wireframe["shapes"] = shapes
|
||||||
|
if connectors:
|
||||||
|
wireframe["connectors"] = connectors
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(wireframe, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
save_mapping(output_path, new_mapping)
|
||||||
|
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Export: push then export as image -----------------------------------------
|
||||||
|
|
||||||
|
def export_image(wireframe_path, output_path, port, fmt="image/png"):
|
||||||
|
"""Push wireframe to Frame0 and export the page as an image."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Push first to ensure Frame0 is up to date
|
||||||
|
push(wireframe_path, port)
|
||||||
|
|
||||||
|
mapping = load_mapping(wireframe_path)
|
||||||
|
page_id = mapping.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
print("ERROR: No page_id in mapping after push", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
image_b64 = api(port, "file:export-image", {
|
||||||
|
"pageId": page_id,
|
||||||
|
"format": fmt,
|
||||||
|
"fillBackground": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
image_bytes = base64.b64decode(image_b64)
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
|
||||||
|
|
||||||
|
|
||||||
|
# -- CLI -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
|
||||||
|
p_push.add_argument("wireframe", help="Path to wireframe .json file")
|
||||||
|
|
||||||
|
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
|
||||||
|
p_pull.add_argument("page", help="Page ID or page name")
|
||||||
|
p_pull.add_argument("output", help="Output .json path")
|
||||||
|
|
||||||
|
p_export = sub.add_parser("export", help="Push and export as image")
|
||||||
|
p_export.add_argument("wireframe", help="Path to wireframe .json file")
|
||||||
|
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
|
||||||
|
p_export.add_argument("--format", default="image/png",
|
||||||
|
help="Export MIME type (default: image/png)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "push":
|
||||||
|
push(args.wireframe, args.port)
|
||||||
|
elif args.command == "pull":
|
||||||
|
pull(args.page, args.output, args.port)
|
||||||
|
elif args.command == "export":
|
||||||
|
export_image(args.wireframe, args.output, args.port, args.format)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -53,6 +53,35 @@ This repo follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
Entries should be short imperative phrases that describe user-facing impact — not implementation detail. "Added sidecar PTY support for terminal pane" beats "Added `internal/pty/session.go`."
|
Entries should be short imperative phrases that describe user-facing impact — not implementation detail. "Added sidecar PTY support for terminal pane" beats "Added `internal/pty/session.go`."
|
||||||
|
|
||||||
|
### Be concise — this is the rule, not a suggestion
|
||||||
|
|
||||||
|
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet** (enforced by `ci/changelog_gate.sh`). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
|
||||||
|
|
||||||
|
The CHANGELOG is read by humans scanning for what changed between two versions. It is **not** the place for the rationale, the probe results, the implementation detail, the behavior-change deep dive, or the "see also" cross-references. Those belong in:
|
||||||
|
|
||||||
|
- the **commit message body** — explain *why*, list evidence, name the trade-offs;
|
||||||
|
- a **D-record / decision document** — durable architectural rationale;
|
||||||
|
- the **ticket / PR description** — work-context and review notes.
|
||||||
|
|
||||||
|
Hard rules:
|
||||||
|
|
||||||
|
- **No multi-paragraph bullets.** One paragraph max. If you reach for a blank line inside a bullet, stop and split or trim.
|
||||||
|
- **No "Behavior change:" / "Side benefit:" / "Note:" sub-headers inside a bullet.** Those are essay structure; put them in the commit message.
|
||||||
|
- **No probe numbers, latency stats, or %-coverage deltas in entries.** ("hit 95% target" is fine; "0 hangs in 300 spawns vs ~5% before" is commit-body material.)
|
||||||
|
- **No nested function/file lists inside parentheses.** If you find yourself writing `(foo, bar, baz, …)` for more than 3 items, just say "several X" and trust the diff.
|
||||||
|
- **Don't restate the title in the body.** A bullet is its own title.
|
||||||
|
|
||||||
|
Calibration — match the **existing entries** in `CHANGELOG.md`. Open it, look at five recent bullets, write to that length. If your draft is visibly bigger than its neighbors, trim until it isn't.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
> - Mouse wheel scrolling in Claude pane — converts scroll events to PgUp/PgDown so TUI apps scroll naturally.
|
||||||
|
|
||||||
|
Bad (verbose; commit-body material leaked in):
|
||||||
|
> - **PTY spawning switched from `forkpty()` to `posix_openpt()` + `posix_spawn()`** (T-96). `forkpty` calls `fork()` underneath, which is unsafe in the multithreaded Dart VM: about 5% of spawns deadlocked in the child before `execve` because libc locks held by ghost-threads remained "locked forever" in the forked child. `posix_spawn` uses `vfork` (glibc/musl/macOS), keeping the parent suspended until `execve` completes — no Dart code runs in the child. Probed: zero hangs in 300 sequential spawns vs ~5% before. **Behavior change:** missing executable / missing workingDirectory now surface as a `PtyException`…
|
||||||
|
|
||||||
|
Better:
|
||||||
|
> - PTY spawning uses `posix_openpt` + `posix_spawn` instead of `forkpty` — closes a ~5% deadlock window in the multithreaded Dart VM (T-96). Missing exe/cwd now throw `PtyException` at spawn time.
|
||||||
|
|
||||||
**What skips the changelog:** pure bookkeeping commits that have no user-visible effect (typo fix in internal comment, `.gitignore` tweak, lint config change, reformatting). When in doubt, add an entry — the harm of an extra line is zero.
|
**What skips the changelog:** pure bookkeeping commits that have no user-visible effect (typo fix in internal comment, `.gitignore` tweak, lint config change, reformatting). When in doubt, add an entry — the harm of an extra line is zero.
|
||||||
|
|
||||||
When a commit spans multiple entries (e.g. a feature that adds one thing and fixes another), add a line under each applicable subsection rather than cramming both into one.
|
When a commit spans multiple entries (e.g. a feature that adds one thing and fixes another), add a line under each applicable subsection rather than cramming both into one.
|
||||||
@@ -64,9 +93,10 @@ Cutting a release is its own commit. In a single commit:
|
|||||||
1. Move all entries from `## [Unreleased]` under a new heading `## [X.Y.Z] — YYYY-MM-DD`.
|
1. Move all entries from `## [Unreleased]` under a new heading `## [X.Y.Z] — YYYY-MM-DD`.
|
||||||
2. Leave an empty `## [Unreleased]` section at the top with its subsection skeleton ready.
|
2. Leave an empty `## [Unreleased]` section at the top with its subsection skeleton ready.
|
||||||
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
|
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
|
||||||
4. Commit subject: `release vX.Y.Z`.
|
4. Run `make gen-build-info` so `assets/licenses.yaml` `self.version:` re-syncs from pubspec (it's auto-rewritten by every build but commit the fresh state). Stage the resulting diff alongside step 3.
|
||||||
|
5. Commit subject: `release vX.Y.Z`.
|
||||||
|
|
||||||
`pubspec.yaml` is the single source of truth for the version — the Makefile reads it for ldflag stamping of the sidecar binary, and the Flutter app reads it for build info. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
|
`pubspec.yaml` is the single source of truth for the version. Every `make` build/run/test target regenerates `lib/src/build_info.g.dart` (gitignored) and rewrites `assets/licenses.yaml` `self.version:` from it — so the Flutter app sees the current version everywhere without manual sync. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
|
||||||
|
|
||||||
## Attribution trailer
|
## Attribution trailer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: penpot-login
|
||||||
|
description: Connect to Penpot MCP - starts the MCP server, logs into penpot.schweitz.net, and installs/connects the plugin
|
||||||
|
allowed-tools: Read, Bash, mcp__chrome-devtools__new_page, mcp__chrome-devtools__navigate_page, mcp__chrome-devtools__take_snapshot, mcp__chrome-devtools__take_screenshot, mcp__chrome-devtools__click, mcp__chrome-devtools__fill, mcp__chrome-devtools__press_key, mcp__chrome-devtools__list_pages
|
||||||
|
---
|
||||||
|
|
||||||
|
# Penpot MCP Connection
|
||||||
|
|
||||||
|
This skill connects Claude to Penpot by:
|
||||||
|
1. Starting the Penpot MCP server (if not running)
|
||||||
|
2. Logging into penpot.schweitz.net via Authentik
|
||||||
|
3. Installing and connecting the Penpot MCP plugin
|
||||||
|
|
||||||
|
## Step 1: Start MCP Server
|
||||||
|
|
||||||
|
Check if the MCP server is running, start it if not:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if server is running
|
||||||
|
if ! curl -s http://localhost:9880/mcp > /dev/null 2>&1; then
|
||||||
|
# Start the server in background
|
||||||
|
cd ~/Projects/penpot-mcp && ./start-server.sh &
|
||||||
|
sleep 3 # Wait for server to start
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Server endpoints when running:
|
||||||
|
- MCP Server: http://localhost:9880/mcp
|
||||||
|
- Plugin Server: http://localhost:9879/
|
||||||
|
- WebSocket: ws://localhost:4402
|
||||||
|
|
||||||
|
## Step 2: Login Credentials
|
||||||
|
|
||||||
|
Read credentials from the project root file `claude-authentik-credentials.md`:
|
||||||
|
!`cat claude-authentik-credentials.md`
|
||||||
|
|
||||||
|
## Step 3: Login Flow
|
||||||
|
|
||||||
|
1. **Navigate to Penpot**
|
||||||
|
- Use `mcp__chrome-devtools__new_page` to go to `https://penpot.schweitz.net`
|
||||||
|
|
||||||
|
2. **Check if already logged in**
|
||||||
|
- Take a snapshot
|
||||||
|
- If you see "Projects" heading, you're logged in - skip to Step 4
|
||||||
|
- If you see login page, continue with authentication
|
||||||
|
|
||||||
|
3. **Authenticate via Authentik** (if not logged in)
|
||||||
|
- Click the OpenID button to redirect to Authentik
|
||||||
|
- On auth.schweitz.net, fill username textbox
|
||||||
|
- Click "Log in" button
|
||||||
|
- Fill password textbox
|
||||||
|
- Click "Continue" button
|
||||||
|
- If redirected to Authentik user page instead of Penpot, navigate back to `https://penpot.schweitz.net`
|
||||||
|
|
||||||
|
## Step 4: Open Design File
|
||||||
|
|
||||||
|
1. **Navigate to the Clide project**
|
||||||
|
- Double-click on the TUI file to open the workspace
|
||||||
|
|
||||||
|
## Step 5: Install/Connect Plugin
|
||||||
|
|
||||||
|
1. **Open Plugins menu**
|
||||||
|
- Click the Plugins button (puzzle icon, keyboard shortcut: Cmd+Alt+P)
|
||||||
|
|
||||||
|
2. **Check if plugin is installed**
|
||||||
|
- If "Penpot MCP Plugin" appears under "INSTALLED PLUGINS", click OPEN
|
||||||
|
- Otherwise, install it first:
|
||||||
|
|
||||||
|
3. **Install plugin** (if not installed)
|
||||||
|
- Fill the plugin URL textbox with: `http://localhost:9879/manifest.json`
|
||||||
|
- Click INSTALL
|
||||||
|
- Click ALLOW on the permissions dialog
|
||||||
|
|
||||||
|
4. **Connect to MCP server**
|
||||||
|
- In the plugin UI, click "CONNECT TO MCP SERVER"
|
||||||
|
- Verify it shows "Connected to MCP server"
|
||||||
|
|
||||||
|
## Step 6: Configure Claude Code MCP
|
||||||
|
|
||||||
|
Add the Penpot MCP server to Claude Code (if not already configured):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add penpot -t http http://localhost:9880/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Use HTTP transport (`-t http`) with the `/mcp` endpoint. Do NOT use SSE transport - it causes "Server not initialized" errors.
|
||||||
|
|
||||||
|
## Step 7: Verify Connection
|
||||||
|
|
||||||
|
After connecting, restart the MCP connection in Claude Code:
|
||||||
|
- Use `/mcp` command to reconnect the penpot server
|
||||||
|
- The Penpot MCP tools (execute_code, export_shape, etc.) will then be available
|
||||||
|
|
||||||
|
Test with:
|
||||||
|
```javascript
|
||||||
|
mcp__penpot__execute_code(code="return penpot.currentPage.name;")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Keep the Penpot plugin UI window open while using MCP tools
|
||||||
|
- The MCP server must be running for the plugin to connect
|
||||||
|
- If connection fails, check that the server is running on port 9880
|
||||||
|
- Use HTTP transport (`-t http`), NOT SSE transport
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
---
|
|
||||||
name: pql
|
|
||||||
description: >
|
|
||||||
Query and plan against a markdown vault via the pql CLI. Two surfaces:
|
|
||||||
(1) structural queries — frontmatter, wikilinks, tags, headings, Bases,
|
|
||||||
DSL — use when the user asks about vault contents ("which notes…", "find
|
|
||||||
where…", "what tags", "who links to X", "run a Base", "query the vault");
|
|
||||||
(2) planning — decision records, tickets, project status — use when the
|
|
||||||
user asks about decisions, tickets, work items, or project planning
|
|
||||||
("sync decisions", "create a ticket", "what's the plan status", "show
|
|
||||||
D-5", "board"). Requires `pql` on PATH. JSON on stdout; exit 2 = zero
|
|
||||||
matches (not an error).
|
|
||||||
---
|
|
||||||
|
|
||||||
# pql — vault queries + project planning
|
|
||||||
|
|
||||||
`pql` indexes a vault into SQLite and exposes structural queries plus a
|
|
||||||
planning layer for decision records and tickets. One binary, two surfaces.
|
|
||||||
|
|
||||||
## Precondition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
command -v pql
|
|
||||||
```
|
|
||||||
|
|
||||||
If absent, tell the user to install from
|
|
||||||
https://github.com/postmeridiem/pql/releases/latest. Don't install it
|
|
||||||
yourself. Don't fall back to grep unless the user explicitly asks.
|
|
||||||
|
|
||||||
## First touch: learn the vault
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pql schema
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns one row per frontmatter key with observed types and file counts.
|
|
||||||
Run once per session before writing queries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Surface 1: Vault queries
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `pql files [glob]` | List indexed files; optional glob filter |
|
|
||||||
| `pql tags [--sort count]` | Distinct tags with counts |
|
|
||||||
| `pql backlinks <path>` | Files linking TO a path |
|
|
||||||
| `pql outlinks <path>` | Links FROM a file |
|
|
||||||
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
|
|
||||||
| `pql schema` | Typed frontmatter schema |
|
|
||||||
| `pql base <name>` | Execute an Obsidian .base file |
|
|
||||||
| `pql shell` | Interactive REPL (indexes once, then query per line) |
|
|
||||||
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
|
|
||||||
| `pql doctor` | Resolved vault/config/DB/index state |
|
|
||||||
|
|
||||||
### DSL examples
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
|
|
||||||
SELECT path WHERE 'project' IN tags ORDER BY path
|
|
||||||
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
|
|
||||||
content into the command line.
|
|
||||||
|
|
||||||
### Query cookbook
|
|
||||||
|
|
||||||
- **Files in folder** → `pql files 'sessions/*'`
|
|
||||||
- **Top tags** → `pql tags --sort count --limit 20`
|
|
||||||
- **What links to X?** → `pql backlinks members/vaasa/persona.md`
|
|
||||||
- **Date range** → `pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
|
|
||||||
- **Run a Base** → `pql base council-sessions`
|
|
||||||
- **Inspect one file** → `pql meta members/vaasa/persona.md --pretty`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Surface 2: Planning (decisions + tickets)
|
|
||||||
|
|
||||||
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
|
|
||||||
cache). Decision records come from `decisions/*.md`; tickets are
|
|
||||||
SQLite-native.
|
|
||||||
|
|
||||||
### Decision subcommands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `pql decisions sync` | Parse decisions/*.md → upsert into pql.db |
|
|
||||||
| `pql decisions validate` | Dry-run parse; exits non-zero on malformed records |
|
|
||||||
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
|
|
||||||
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
|
|
||||||
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
|
|
||||||
| `pql decisions coverage` | Confirmed decisions without tickets |
|
|
||||||
| `pql decisions refs <id>` | Cross-references involving a decision |
|
|
||||||
|
|
||||||
Always `pql decisions sync` before querying if decisions/*.md may have changed.
|
|
||||||
|
|
||||||
### Ticket subcommands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `pql ticket new <type> "title" [--decision D-NNN] [--priority P]` | Create (emits T-NNN) |
|
|
||||||
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L]` | List with filters |
|
|
||||||
| `pql ticket show <id> [--with-context] [--with-blockers]` | Show with joins (context = ancestors + decisions + children) |
|
|
||||||
| `pql ticket status <id[,id,...]> <new-status>` | Transition (batch via comma-separated IDs) |
|
|
||||||
| `pql ticket assign <id> <agent>` | Set assignee |
|
|
||||||
| `pql ticket block <id> --by <other>` | Add blocker |
|
|
||||||
| `pql ticket unblock <id> --from <other>` | Remove blocker |
|
|
||||||
| `pql ticket team <id> <team>` | Set team |
|
|
||||||
| `pql ticket label <id> add\|rm <label>` | Manage labels |
|
|
||||||
| `pql ticket board [--team T]` | Kanban board view |
|
|
||||||
|
|
||||||
Ticket types: initiative, epic, story, task, bug.
|
|
||||||
Status flow: backlog → ready → in_progress → review → done (also cancelled).
|
|
||||||
|
|
||||||
### Plan subcommands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
|
|
||||||
| `pql plan export [--to FILE]` | Snapshot planning state to JSON (default: `pql-plan.json`) |
|
|
||||||
| `pql plan import [--from FILE]` | Restore planning state from a JSON snapshot |
|
|
||||||
|
|
||||||
### Versioning planning state
|
|
||||||
|
|
||||||
Planning state lives in `pql.db` (gitignored). To version it in git,
|
|
||||||
use `pql plan export` to write a committed JSON snapshot. pql does NOT
|
|
||||||
do this automatically — the user decides when and how to trigger it:
|
|
||||||
|
|
||||||
- Pre-push hook: `.githooks/pre-push` calls `pql plan export && git add pql-plan.json`
|
|
||||||
- Sprint close: a skill or script exports + commits on milestone
|
|
||||||
- Manual: run `pql plan export` before committing when state changed
|
|
||||||
|
|
||||||
On a fresh clone, `pql plan import` restores from the snapshot.
|
|
||||||
|
|
||||||
### Planning cookbook
|
|
||||||
|
|
||||||
- **Sync and list confirmed** → `pql decisions sync && pql decisions list --type confirmed`
|
|
||||||
- **Show with refs** → `pql decisions show D-5 --with-refs --pretty`
|
|
||||||
- **Create ticket** → `pql ticket new task "implement X" --decision D-5`
|
|
||||||
- **Batch close** → `pql ticket status T-1,T-2,T-3 done`
|
|
||||||
- **Coverage gaps** → `pql decisions coverage`
|
|
||||||
- **Dashboard** → `pql plan status --pretty`
|
|
||||||
- **Snapshot for git** → `pql plan export`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Output contract (both surfaces)
|
|
||||||
|
|
||||||
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
|
|
||||||
- **stderr:** JSON diagnostics `{"level":"…","code":"pql.<phase>.<kind>","msg":"…"}`.
|
|
||||||
- **Exit codes:**
|
|
||||||
- `0` — success, ≥1 result
|
|
||||||
- `2` — zero matches (not an error — say "no matches", not "failed")
|
|
||||||
- `64` — bad flag
|
|
||||||
- `65` — parse/compile error (pass stderr back)
|
|
||||||
- `66` — vault/config not found
|
|
||||||
- `69` — unavailable
|
|
||||||
- `70` — internal error
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
|
|
||||||
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
|
|
||||||
- Don't parse errors — pass stderr diagnostics back directly.
|
|
||||||
- Don't forget `pql decisions sync` before querying decisions.
|
|
||||||
- Don't try to install or upgrade pql — instruct the user if missing.
|
|
||||||
|
|
||||||
## When NOT to use
|
|
||||||
|
|
||||||
- **Body text search** → `grep`/`rg`.
|
|
||||||
- **Reading file contents** → `Read` tool.
|
|
||||||
- **Code structure** → tree-sitter / LSP.
|
|
||||||
- **Modifying vault files** → `Write`/`Edit`. pql doesn't write to vault content.
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
|
|
||||||
The consuming project's `.claude/settings.json` should allow:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(pql)", "Bash(pql *)"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updating the skill
|
|
||||||
|
|
||||||
`pql skill status` reports drift. `pql skill install` writes/updates;
|
|
||||||
`--force` overrides hand-edits. `pql doctor` also surfaces skill state.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
---
|
|
||||||
name: theme-ui
|
|
||||||
description: >-
|
|
||||||
Token selection guide for clide UI development. Use when building or
|
|
||||||
modifying widgets, panels, pane chrome, status indicators, icons, or
|
|
||||||
any visual surface. Ensures correct background, border, text, and hover
|
|
||||||
tokens are applied per surface type. Triggers on: new widget code,
|
|
||||||
theme-related changes, "which token", "what color", color/background
|
|
||||||
questions, visual inconsistency fixes, new panel/pane/view development,
|
|
||||||
adding or looking up Phosphor icons, icon codepoints.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Theme-UI — token selection for clide surfaces
|
|
||||||
|
|
||||||
All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
|
|
||||||
Never hardcode colors. Never use Material/Cupertino color constants.
|
|
||||||
|
|
||||||
## Token selection by surface
|
|
||||||
|
|
||||||
Pick tokens based on **where** the widget lives, not what it does.
|
|
||||||
|
|
||||||
### Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
|
|
||||||
|
|
||||||
```
|
|
||||||
background → chromeBackground
|
|
||||||
text → chromeForeground
|
|
||||||
border → chromeBorder (1px)
|
|
||||||
active text → globalForeground
|
|
||||||
```
|
|
||||||
|
|
||||||
### Side panels (sidebar, context panel)
|
|
||||||
|
|
||||||
```
|
|
||||||
background → chromeBackground (both sides — they're chrome frame)
|
|
||||||
text → sidebarForeground
|
|
||||||
hover → sidebarItemHover
|
|
||||||
selected → sidebarItemSelected
|
|
||||||
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
|
|
||||||
```
|
|
||||||
|
|
||||||
Padding: 2px on outer edges, 0px on divider edge.
|
|
||||||
|
|
||||||
### Center column (workspace, Claude pane, editor)
|
|
||||||
|
|
||||||
```
|
|
||||||
background → panelBackground
|
|
||||||
text → globalForeground
|
|
||||||
```
|
|
||||||
|
|
||||||
No padding — content fills edge to edge.
|
|
||||||
|
|
||||||
### Pane headers (`ClidePaneChrome`)
|
|
||||||
|
|
||||||
```
|
|
||||||
background → panelHeader
|
|
||||||
text (title) → panelHeaderForeground
|
|
||||||
text (sub) → globalTextMuted
|
|
||||||
```
|
|
||||||
|
|
||||||
### List items (decisions, tickets, file rows, backlinks)
|
|
||||||
|
|
||||||
```
|
|
||||||
background → (none / transparent)
|
|
||||||
hover bg → listItemHoverBackground
|
|
||||||
selected bg → listItemSelectedBackground
|
|
||||||
text → listItemForeground / sidebarForeground (in sidebar)
|
|
||||||
selected txt → listItemSelectedForeground
|
|
||||||
```
|
|
||||||
|
|
||||||
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
|
|
||||||
```
|
|
||||||
normal → buttonBackground / buttonForeground / buttonBorder
|
|
||||||
hover → buttonHoverBackground
|
|
||||||
active → buttonActiveBackground
|
|
||||||
primary → buttonActiveBackground bg + globalBackground text
|
|
||||||
subtle → listItemBackground / listItemHoverBackground (no border)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dividers and separators
|
|
||||||
|
|
||||||
```
|
|
||||||
line → dividerColor (always, everywhere)
|
|
||||||
drag handle → 8px hit area, 1px visible line, panel bg fill
|
|
||||||
hover line → panelActiveBorder
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status indicators
|
|
||||||
|
|
||||||
```
|
|
||||||
success/ok → statusSuccess (green: done, added, connected)
|
|
||||||
warning → statusWarning (amber: question, modified, missing)
|
|
||||||
error → statusError (red: deleted, rejected, cancelled)
|
|
||||||
info → statusInfo (blue: in_progress, modified)
|
|
||||||
```
|
|
||||||
|
|
||||||
Map semantic states, not visual styles:
|
|
||||||
- `done` / `added` / `ok` → `statusSuccess`
|
|
||||||
- `in_progress` / `modified` → `statusInfo`
|
|
||||||
- `question` / `warning` → `statusWarning`
|
|
||||||
- `cancelled` / `deleted` / `error` → `statusError`
|
|
||||||
|
|
||||||
### Overlays (dialogs, palette, tooltips)
|
|
||||||
|
|
||||||
```
|
|
||||||
dialog bg → modalSurfaceBackground
|
|
||||||
dialog border→ modalSurfaceBorder
|
|
||||||
backdrop → modalOverlayBackground
|
|
||||||
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
|
||||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
|
||||||
```
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
Three constants — never hardcode sizes or families.
|
|
||||||
|
|
||||||
```
|
|
||||||
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
|
|
||||||
family mono → clideMonoFamily (JetBrainsMono)
|
|
||||||
body size → clideFontBody (15)
|
|
||||||
caption size → clideFontCaption (14) — status bar, section headers, git info
|
|
||||||
mono size → clideFontMono (14) — terminal, code, paths, IDs
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `ClideText` for themed text. Set `muted: true` for secondary text
|
|
||||||
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
|
|
||||||
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
|
|
||||||
|
|
||||||
## Token identity rule
|
|
||||||
|
|
||||||
Every visual surface gets its own named token. Never borrow a token from
|
|
||||||
another surface just because they happen to resolve to the same color.
|
|
||||||
|
|
||||||
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
|
|
||||||
**Right:** Create `chromeBackground` that resolves to the same palette key.
|
|
||||||
|
|
||||||
When two surfaces share a color:
|
|
||||||
1. **If they're the same conceptual surface** (sidebar + context panel are
|
|
||||||
both "side panels") → one shared token set is fine.
|
|
||||||
2. **If they're different surfaces that happen to match** (hat bar + sidebar
|
|
||||||
+ status bar are all "chrome frame") → create a shared primitive in the
|
|
||||||
palette/semantic layer (e.g. `bgChrome`) and give each surface its own
|
|
||||||
token that maps to that primitive. This lets themes diverge them later.
|
|
||||||
|
|
||||||
The palette layer has these depth primitives:
|
|
||||||
- `bg` (`#20202C`) — outermost root, behind everything
|
|
||||||
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
|
|
||||||
- `surface` (`#242838`) — elevated: pane headers, active tabs
|
|
||||||
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
|
|
||||||
|
|
||||||
Chrome tokens (`chromeBackground`/`chromeForeground`/`chromeBorder`) are
|
|
||||||
the shared root for all frame surfaces. They resolve to `bgSunken` /
|
|
||||||
`textDim` / `border` in the palette. Themes can override them to diverge
|
|
||||||
hat from sidebar from status bar if desired.
|
|
||||||
|
|
||||||
## Extension-owned domain colors
|
|
||||||
|
|
||||||
Extensions that need domain-specific color coding (ticket types, decision
|
|
||||||
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
|
||||||
|
|
||||||
1. Create a color map class in the extension (e.g. `TicketTypeColors`)
|
|
||||||
2. Ship dark and light presets, auto-selected via `ClideTheme.of(context).dark`
|
|
||||||
3. Store user overrides under `ext.<id>.colors` in settings
|
|
||||||
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`
|
|
||||||
|
|
||||||
This keeps the core token surface lean and lets each extension own its
|
|
||||||
palette. The pattern scales to any extension needing domain colors.
|
|
||||||
|
|
||||||
## Icons — Phosphor Icons
|
|
||||||
|
|
||||||
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
|
||||||
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
|
||||||
|
|
||||||
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
|
|
||||||
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
|
||||||
names. Read this file to look up any icon by name or codepoint.
|
|
||||||
|
|
||||||
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
|
|
||||||
a `static const` entry to `PhosphorIcons` in
|
|
||||||
`lib/widgets/src/icons/phosphor.dart`:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
|
||||||
```
|
|
||||||
|
|
||||||
Only add icons we actually use — don't bulk-import the full set.
|
|
||||||
|
|
||||||
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
|
|
||||||
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
|
||||||
|
|
||||||
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
|
||||||
Fill weight: `family: 'Phosphor-Fill'`.
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- Borrowing another surface's token (`sidebarBackground` for hat bar)
|
|
||||||
- `globalBackground` for panel fill → use `panelBackground`
|
|
||||||
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
|
||||||
- Hardcoded `Color(0xFF...)` → use a token
|
|
||||||
- `fontSize: 14` → use `clideFontCaption` or `clideFontMono`
|
|
||||||
- `fontFamily: 'JetBrainsMono'` → use `clideMonoFamily`
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
Full token list: `lib/kernel/src/theme/tokens.dart`
|
|
||||||
Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
|
|
||||||
Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: ui-design
|
||||||
|
description: >-
|
||||||
|
Visual design guide for clide UI development — covers theme tokens,
|
||||||
|
surface-specific token selection, control geometry/spacing/alignment,
|
||||||
|
and Phosphor icons. Use when building or modifying widgets, panels,
|
||||||
|
pane chrome, status indicators, tabs, list items, dialogs, or any
|
||||||
|
visual surface. Triggers on: new widget code, theme-related changes,
|
||||||
|
"which token", "what color", color/background questions, visual
|
||||||
|
inconsistency fixes, "alignment off", "spacing", "padding", control
|
||||||
|
geometry questions, new panel/pane/view development, adding or
|
||||||
|
looking up Phosphor icons, icon codepoints.
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI design — clide visual surface guide
|
||||||
|
|
||||||
|
This skill bundles four concerns that all surface in widget work:
|
||||||
|
|
||||||
|
| Concern | Reference | When to read |
|
||||||
|
|---------|-----------|--------------|
|
||||||
|
| Token system, palette, typography | [`references/theme.md`](references/theme.md) | Designing or extending the theme pipeline; deciding whether to add a new token |
|
||||||
|
| Token selection per surface | [`references/surface.md`](references/surface.md) | Building a new widget or modifying an existing one — "which token does this need" |
|
||||||
|
| Spacing, alignment, control layout | [`references/geometry.md`](references/geometry.md) | Building tab strips, list items, buttons, anything where icons sit next to text or padded edges |
|
||||||
|
| Phosphor icon usage and codepoints | [`references/icons.md`](references/icons.md) | Adding or referencing an icon |
|
||||||
|
| Full Phosphor glyph table (1512, with codepoints) | [`references/phosphor-glyphs.md`](references/phosphor-glyphs.md) | Picking a specific glyph by name/look — find its codepoint, see if it's already defined |
|
||||||
|
|
||||||
|
Read the reference that matches the question. They cross-reference each
|
||||||
|
other where relevant; you don't need to read all four.
|
||||||
|
|
||||||
|
## Universal rules
|
||||||
|
|
||||||
|
These apply across every reference and every surface:
|
||||||
|
|
||||||
|
- All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
|
||||||
|
Never hardcode `Color(0xFF...)`.
|
||||||
|
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
||||||
|
is `WidgetsApp` only (D-7).
|
||||||
|
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
||||||
|
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||||
|
status/section headers, body inherits from `DefaultTextStyle`.
|
||||||
|
|
||||||
|
## Conversation-panel cards (T-305)
|
||||||
|
|
||||||
|
The Claude conversation stream has **three** card categories. They are NOT one
|
||||||
|
shared wrapper widget — each is its own widget; they only share the spacing
|
||||||
|
constants in `lib/widgets/src/clide_card_metrics.dart` (`kClideCardGap`,
|
||||||
|
`kClideCardRadius`, `kClideCardHeaderPadH/V`, `kClideCardCounterSlotWidth`) so
|
||||||
|
the stream reads as one rhythm. Change spacing there, not per-card.
|
||||||
|
|
||||||
|
1. **Dialog cards** — `ConversationCard` with a speaker **side stripe** (you /
|
||||||
|
claude / agent). Prose/attribution; not collapsible.
|
||||||
|
2. **Simple cards** — a single item shown fully open, never collapses (e.g. the
|
||||||
|
image card). Standalone display; no chevron, no status chrome.
|
||||||
|
3. **Collapsibles** — `ClideCollapserCard` (`lib/widgets/`). Every tool use is
|
||||||
|
one, over a list of `1..N` inner item cards (a single tool = a 1-item list;
|
||||||
|
there is no separate single-card path). Rules:
|
||||||
|
- Whole card toggles; chevron hard against the **left** edge, the status tick
|
||||||
|
(spinner/check/cross) hard against the **right** edge, the count in a
|
||||||
|
fixed-width slot just inboard of it.
|
||||||
|
- `color` drives the border + chevron/label tint (per-instance fidelity).
|
||||||
|
- Collapsed ticker echoes the run's **last content line** as the title +
|
||||||
|
count + aggregate status (computed by the caller, bubbled up from items).
|
||||||
|
- Inner item cards are content + their **own** per-item status; they pass
|
||||||
|
`margin: EdgeInsets.zero`-ish (a bottom margin matching the canvas) so the
|
||||||
|
collapser pads the inner canvas **evenly on all sides** — never let an
|
||||||
|
inner card jam under the header or against a frame edge.
|
||||||
|
|
||||||
|
Do NOT pull a dialog card's stripe or a simple card's config into the collapser,
|
||||||
|
and do NOT nest collapsers — inside a run, tools render as the bare inner content
|
||||||
|
card (`_ConversationTurn(collapseTools: false)`).
|
||||||
|
|
||||||
|
## Anti-patterns (cross-cutting)
|
||||||
|
|
||||||
|
- Borrowing another surface's token (`sidebarBackground` for hat bar) — give
|
||||||
|
each surface its own token even if they share a palette key. See `theme.md`.
|
||||||
|
- Hardcoded hex colors → use a token. See `surface.md` for which one.
|
||||||
|
- `fontSize: 14` literal → use `clideFontCaption` or `clideFontMono`.
|
||||||
|
- `fontFamily: 'JetBrainsMono'` literal → use `clideMonoFamily`.
|
||||||
|
- Stacking edge padding on a padded parent + a padded child action → see
|
||||||
|
`geometry.md` "no double edge padding".
|
||||||
|
- Eyeballing pixel margins instead of working back from the constraint —
|
||||||
|
the math matters; see `geometry.md` "uniform inner spacing".
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Geometry — spacing, alignment, control layout
|
||||||
|
|
||||||
|
Principles for placing icons, buttons, and text inside controls.
|
||||||
|
Apply when building tab strips, list items, buttons with affordances,
|
||||||
|
or anything where actions sit next to content.
|
||||||
|
|
||||||
|
> Constants live in `lib/widgets/src/spacing.dart` — pull from there
|
||||||
|
> instead of inlining literals:
|
||||||
|
>
|
||||||
|
> | Concept | Constant |
|
||||||
|
> |-------------------------|-------------------------|
|
||||||
|
> | Hairline (2px) | `clideInsetHairline` |
|
||||||
|
> | Tight inset (4px) | `clideInsetTight` |
|
||||||
|
> | Uniform icon margin (6) | `clideInsetIcon` |
|
||||||
|
> | Standard inset (8px) | `clideInsetStandard` |
|
||||||
|
> | Text-content inset (12) | `clideInsetText` |
|
||||||
|
> | Tight / standard gap | `clideGapTight` / `clideGapStandard` |
|
||||||
|
> | Section / major gap | `clideGapSection` / `clideGapMajor` |
|
||||||
|
> | Micro icon (10) | `clideIconMicro` |
|
||||||
|
> | Standard icon (14) | `clideIconStandard` |
|
||||||
|
> | Hit-target (16) | `clideIconHitTarget` |
|
||||||
|
> | Control height (28) | `clideControlHeight` |
|
||||||
|
|
||||||
|
## Uniform inner spacing rule
|
||||||
|
|
||||||
|
Icons inside control surfaces should have **equal margin on every
|
||||||
|
constrained side**. The "constrained sides" are top, bottom, and the
|
||||||
|
side opposite to where content flows in.
|
||||||
|
|
||||||
|
The remaining side — where the text or other content sits — gets a
|
||||||
|
larger, content-appropriate breathing room.
|
||||||
|
|
||||||
|
Example: tab close button (16×16 inside a 28-tall tab):
|
||||||
|
|
||||||
|
```
|
||||||
|
top : 6 ┐
|
||||||
|
bottom : 6 ├─ uniform: (28 − 16) / 2 = 6
|
||||||
|
right : 6 ┘
|
||||||
|
left : 8 ── content gap (separates from title text)
|
||||||
|
```
|
||||||
|
|
||||||
|
The visual effect: the close button looks like a deliberate
|
||||||
|
affordance with a calm, consistent border, not a glyph stuffed into
|
||||||
|
the corner.
|
||||||
|
|
||||||
|
## No double-edge padding
|
||||||
|
|
||||||
|
When a fixed-size action (icon button, close ×) sits at the edge of
|
||||||
|
a padded parent, the parent's padding on that edge should **not stack**
|
||||||
|
with the action's own internal margin. Pick one place to hold the
|
||||||
|
breathing room.
|
||||||
|
|
||||||
|
Wrong:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12), // tab pad: 12 right
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: title),
|
||||||
|
SizedBox(width: 8), // gap: 8
|
||||||
|
Container(width: 28, alignment: Center, // close: 6 internal margin
|
||||||
|
child: Icon(close, size: 16)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
// Visible margin from icon right to outer right = 12 + 6 = 18px → too much
|
||||||
|
```
|
||||||
|
|
||||||
|
Right:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(left: 12, right: 6), // pad matches icon margin
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: title),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Container(width: 16, height: 16, alignment: Center, // hit target = icon size
|
||||||
|
child: Icon(close, size: 10)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
// Visible margin = 6 (parent right pad) ≈ 6 (top/bottom auto) → uniform
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two-column control pattern
|
||||||
|
|
||||||
|
For tab-shaped or row-shaped controls with a primary content area and
|
||||||
|
a secondary action:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: <content>), // takes remainder
|
||||||
|
if (action != null) ...[
|
||||||
|
SizedBox(width: 8), // standard gap
|
||||||
|
<fixed-size action>, // shrinks to content
|
||||||
|
],
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Left column**: `Expanded`, holds the primary content (title,
|
||||||
|
label, description). Aligned to the start of its space by default.
|
||||||
|
- **Right column**: fixed natural width, holds the action (close,
|
||||||
|
status, indicator). Sized to the icon, not to artificial padding.
|
||||||
|
|
||||||
|
The parent container's padding sits flush against both columns (see
|
||||||
|
"no double-edge padding").
|
||||||
|
|
||||||
|
## Match perceived mass, not measured pixels
|
||||||
|
|
||||||
|
Glyphs vary in visual weight. A bold `+` looks heavier than a thin
|
||||||
|
`×` at the same point size. When eyeballing alignment, trust the
|
||||||
|
optical center over the geometric center.
|
||||||
|
|
||||||
|
In practice: if two icons measure to the same margin but one *looks*
|
||||||
|
crowded, give the heavier glyph slightly more breathing room and
|
||||||
|
trim the lighter one. For clide, this came up with the `×` close
|
||||||
|
glyph vs the `+` add glyph — both at 14pt, but `+` reads as denser
|
||||||
|
and is left in its 28-wide button without further padding, while
|
||||||
|
`×` sits in a 16×16 hit area with 6px symmetric margin.
|
||||||
|
|
||||||
|
## Strip / row should fill the parent
|
||||||
|
|
||||||
|
Tab strips, status bars, and divider rows should span the full
|
||||||
|
parent width, not size to their content. Without this, the strip
|
||||||
|
looks like it floats inside the pane.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, // <-- this
|
||||||
|
children: [
|
||||||
|
_TabStrip(...),
|
||||||
|
Expanded(child: _body(...)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `stretch`, Column gives loose width constraints and a
|
||||||
|
`Container(height: tabHeight)` child sizes to its child's natural
|
||||||
|
width — the strip ends mid-pane.
|
||||||
|
|
||||||
|
## Anchor strips with a divider
|
||||||
|
|
||||||
|
Add a 1px bottom border (`dividerColor`) to tab strips and any
|
||||||
|
header strip that sits above content. Without it, the strip looks
|
||||||
|
disconnected from the body and the perceived alignment slips.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Container(
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tokens.tabBarBackground,
|
||||||
|
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
|
||||||
|
),
|
||||||
|
child: ...,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Centering a glyph inside a "hover background" that's larger than
|
||||||
|
the natural icon size, then surrounding the whole thing with a
|
||||||
|
padded parent — the icon ends up far inside the visible edge.
|
||||||
|
- Hardcoded `padding: EdgeInsets.symmetric(horizontal: 12)` on every
|
||||||
|
control regardless of whether the right edge has an action — see
|
||||||
|
"no double-edge padding".
|
||||||
|
- Tab strip inside `Column` without `crossAxisAlignment.stretch` —
|
||||||
|
the strip ends mid-pane.
|
||||||
|
- `mainAxisSize.min` on the tab strip's outer Row when you actually
|
||||||
|
want it to fill parent width — only use `min` for pill-shaped
|
||||||
|
controls that should hug their content.
|
||||||
|
- Eyeballing alignment without working back from a target margin in
|
||||||
|
pixels. The math matters; see "uniform inner spacing".
|
||||||
|
|
||||||
|
## Ultrawide — clide is an IDE, assume wide screens (T-239)
|
||||||
|
|
||||||
|
The default `flutter_test` surface is 800px, but clide runs on 3440/5120
|
||||||
|
ultrawide constantly. Two consequences:
|
||||||
|
|
||||||
|
- **Right-align with `Expanded`, not a `Spacer` that fights a flex sibling.**
|
||||||
|
A `Row[ left…, Flexible(flex:1), Spacer(), right… ]` splits the free space
|
||||||
|
between the loose flex item and the `Spacer` 50/50 — so the right group is
|
||||||
|
pushed only *half* the free space. The drift is **proportional to width**:
|
||||||
|
invisible at 800–1200px, ~1500px adrift at 3440px. To pin a right group to
|
||||||
|
the edge regardless of width, put the left group in `Expanded(Row[...])`
|
||||||
|
(it absorbs *all* free space; flex items flex within it) and let the right
|
||||||
|
group trail at intrinsic width. See `StatusbarHost` in `lib/app.dart`.
|
||||||
|
- **Test layout at ultrawide, not just the default surface.** Width-sensitive
|
||||||
|
bugs hide at 800px. Drive the surface with `tester.view.physicalSize` —
|
||||||
|
a wide `SizedBox` under the default 800px surface is CLAMPED to 800, so it
|
||||||
|
doesn't actually test wide. Pattern in `test/app_statusbar_test.dart`
|
||||||
|
(`pumpAt`): set `view.physicalSize`, assert key positions at a normal AND an
|
||||||
|
ultrawide width. Audit tracked in T-241.
|
||||||
|
|
||||||
|
## Testing alignment
|
||||||
|
|
||||||
|
When iterating on a control's spacing:
|
||||||
|
|
||||||
|
1. State the target margin (e.g. "6px around the close icon, all
|
||||||
|
sides except left").
|
||||||
|
2. Map every contributing source: parent padding, gap SizedBoxes,
|
||||||
|
container alignment offsets, icon-to-container size differences.
|
||||||
|
3. Sum them. Adjust until they hit the target.
|
||||||
|
4. Verify visually — perceived mass may justify a 1–2px tweak.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Icons — Phosphor + clide-owned painters
|
||||||
|
|
||||||
|
## Phosphor Icons
|
||||||
|
|
||||||
|
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||||
|
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||||
|
|
||||||
|
**Glyph reference:** [`phosphor-glyphs.md`](phosphor-glyphs.md) — the full
|
||||||
|
1512-glyph table (codepoint · kebab name · Pascal name). Grep it for the exact
|
||||||
|
kebab name; **all 1512 are available** — no per-icon wiring (T-314).
|
||||||
|
|
||||||
|
### Using an icon
|
||||||
|
|
||||||
|
Reference a glyph by its **exact kebab-case name** — no codepoints in feature
|
||||||
|
code; the label→codepoint table lives only in the generated
|
||||||
|
`lib/widgets/src/icons/phosphor_glyphs.g.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as a `TabContribution` icon field: `icon: PhosphorIcons.byName('lightbulb')`.
|
||||||
|
|
||||||
|
- An **unknown name** is a bug — it degrades to the `placeholder` error box so
|
||||||
|
the mistake is visible. `phosphor_glyphs_test` asserts every `byName('…')`
|
||||||
|
literal in `lib/` resolves, recovering the typo check a const would give.
|
||||||
|
- For an **intentional blank** that still reserves the icon's box (alignment),
|
||||||
|
use `const EmptyIconPainter()` — *not* a missing name.
|
||||||
|
- Regenerate the map after a font bump: `dart run tool/gen_phosphor_glyphs.dart`.
|
||||||
|
|
||||||
|
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||||
|
**Fill weight:** `family: 'Phosphor-Fill'`.
|
||||||
|
|
||||||
|
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||||
|
**Fill weight:** `family: 'Phosphor-Fill'`.
|
||||||
|
|
||||||
|
## clide-owned painters
|
||||||
|
|
||||||
|
Some shapes are simple enough to paint directly without an icon
|
||||||
|
font. Hand-rolled `ClideIconPainter` subclasses live under
|
||||||
|
`lib/widgets/src/icons/`:
|
||||||
|
|
||||||
|
- `CheckIcon`, `ChevronIcon`, `CloseIcon` (`x.dart`)
|
||||||
|
- `DotIcon`, `FolderIcon`, `GearIcon`
|
||||||
|
- `GitBranchIcon`, `PlugIcon`, `SearchIcon`
|
||||||
|
- `TerminalIcon`, `WarningIcon`
|
||||||
|
|
||||||
|
Use these for tiny, theme-aware glyphs (close ×, dropdown chevrons,
|
||||||
|
status dots) where pulling in the Phosphor font weight would be
|
||||||
|
overkill or where the visual needs to match the theme's stroke
|
||||||
|
weight conventions.
|
||||||
|
|
||||||
|
Pattern for a new painter:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class FoobarIcon extends ClideIconPainter {
|
||||||
|
const FoobarIcon();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Color color) {
|
||||||
|
final p = Paint()
|
||||||
|
..color = color
|
||||||
|
..strokeWidth = 0.10
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
// Coordinates are 0..1 (the painter is given a unit square).
|
||||||
|
canvas.drawLine(const Offset(0.2, 0.2), const Offset(0.8, 0.8), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sizing
|
||||||
|
|
||||||
|
Icon sizes used in clide (subject to consolidation under
|
||||||
|
`ClideSpacing` — see T-86):
|
||||||
|
|
||||||
|
- `10` — micro: close × inside a tab
|
||||||
|
- `13` — caption-row icons (sidebar, status bar)
|
||||||
|
- `14` — standard inline icons (icon rail)
|
||||||
|
- `16` — small icon hit-target outer container
|
||||||
|
- `18`–`20` — emphatic / standalone icons
|
||||||
|
|
||||||
|
Pass `size:` to `ClideIcon`; the painter receives a unit-square
|
||||||
|
canvas regardless. Color defaults to `globalForeground`; pass
|
||||||
|
explicit `color:` for muted/active variants.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Importing all of Phosphor — only declare codepoints we use.
|
||||||
|
- Hand-painting a glyph that already exists in Phosphor at the right
|
||||||
|
weight — use the font.
|
||||||
|
- Hardcoded `Color` on icons — pass through the surface tokens
|
||||||
|
(`globalForeground`, `globalTextMuted`, `panelActiveBorder`, etc.).
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Surface — token selection per surface type
|
||||||
|
|
||||||
|
Pick tokens based on **where** the widget lives, not what it does.
|
||||||
|
|
||||||
|
## Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
|
||||||
|
|
||||||
|
```
|
||||||
|
background → chromeBackground
|
||||||
|
text → chromeForeground
|
||||||
|
border → chromeBorder (1px)
|
||||||
|
active text → globalForeground
|
||||||
|
```
|
||||||
|
|
||||||
|
## Side panels (sidebar, context panel)
|
||||||
|
|
||||||
|
```
|
||||||
|
background → chromeBackground (both sides — they're chrome frame)
|
||||||
|
text → sidebarForeground
|
||||||
|
hover → sidebarItemHover
|
||||||
|
selected → sidebarItemSelected
|
||||||
|
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Padding: 2px on outer edges, 0px on divider edge.
|
||||||
|
|
||||||
|
## Center column (workspace, Claude pane, editor)
|
||||||
|
|
||||||
|
```
|
||||||
|
background → panelBackground
|
||||||
|
text → globalForeground
|
||||||
|
```
|
||||||
|
|
||||||
|
No padding — content fills edge to edge.
|
||||||
|
|
||||||
|
## Pane headers (`ClidePaneChrome`)
|
||||||
|
|
||||||
|
```
|
||||||
|
background → panelHeader
|
||||||
|
text (title) → panelHeaderForeground
|
||||||
|
text (sub) → globalTextMuted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tabs (`MultitabPane`, `ClideTabBar`)
|
||||||
|
|
||||||
|
```
|
||||||
|
strip bg → tabBarBackground
|
||||||
|
strip border → bottom: dividerColor (anchors strip to body)
|
||||||
|
active fg → tabActiveForeground
|
||||||
|
inactive fg → tabInactiveForeground
|
||||||
|
active bg → panelHeader (elevated chrome)
|
||||||
|
inactive bg → tabBarBackground (blends with strip)
|
||||||
|
active border→ panelActiveBorder (top accent, 1.5px)
|
||||||
|
side border → panelBorder
|
||||||
|
```
|
||||||
|
|
||||||
|
For control geometry inside tabs (close button placement, padding,
|
||||||
|
two-column title+action layout) see [`geometry.md`](geometry.md).
|
||||||
|
|
||||||
|
## List items (decisions, tickets, file rows, backlinks)
|
||||||
|
|
||||||
|
```
|
||||||
|
background → (none / transparent)
|
||||||
|
hover bg → listItemHoverBackground
|
||||||
|
selected bg → listItemSelectedBackground
|
||||||
|
text → listItemForeground / sidebarForeground (in sidebar)
|
||||||
|
selected txt → listItemSelectedForeground
|
||||||
|
```
|
||||||
|
|
||||||
|
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
|
||||||
|
|
||||||
|
## Buttons
|
||||||
|
|
||||||
|
```
|
||||||
|
normal → buttonBackground / buttonForeground / buttonBorder
|
||||||
|
hover → buttonHoverBackground
|
||||||
|
active → buttonActiveBackground
|
||||||
|
primary → buttonActiveBackground bg + globalBackground text
|
||||||
|
subtle → listItemBackground / listItemHoverBackground (no border)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dividers and separators
|
||||||
|
|
||||||
|
```
|
||||||
|
line → dividerColor (always, everywhere)
|
||||||
|
drag handle → 8px hit area, 1px visible line, panel bg fill
|
||||||
|
hover line → panelActiveBorder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status indicators
|
||||||
|
|
||||||
|
```
|
||||||
|
success/ok → statusSuccess (green: done, added, connected)
|
||||||
|
warning → statusWarning (amber: question, modified, missing)
|
||||||
|
error → statusError (red: deleted, rejected, cancelled)
|
||||||
|
info → statusInfo (blue: in_progress, modified)
|
||||||
|
```
|
||||||
|
|
||||||
|
Map semantic states, not visual styles:
|
||||||
|
|
||||||
|
- `done` / `added` / `ok` → `statusSuccess`
|
||||||
|
- `in_progress` / `modified` → `statusInfo`
|
||||||
|
- `question` / `warning` → `statusWarning`
|
||||||
|
- `cancelled` / `deleted` / `error` → `statusError`
|
||||||
|
|
||||||
|
## Overlays (dialogs, palette, tooltips)
|
||||||
|
|
||||||
|
```
|
||||||
|
dialog bg → modalSurfaceBackground
|
||||||
|
dialog border→ modalSurfaceBorder
|
||||||
|
backdrop → modalOverlayBackground
|
||||||
|
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||||
|
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- `globalBackground` for panel fill → use `panelBackground`
|
||||||
|
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||||
|
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
||||||
|
- Tab active border = `globalFocus` → use `panelActiveBorder`
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Theme — token system, palette, typography
|
||||||
|
|
||||||
|
## Token identity rule
|
||||||
|
|
||||||
|
Every visual surface gets its own named token. Never borrow a token from
|
||||||
|
another surface just because they happen to resolve to the same color.
|
||||||
|
|
||||||
|
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
|
||||||
|
**Right:** Create `chromeBackground` that resolves to the same palette key.
|
||||||
|
|
||||||
|
When two surfaces share a color:
|
||||||
|
|
||||||
|
1. **Same conceptual surface** (sidebar + context panel are both "side
|
||||||
|
panels") → one shared token set is fine.
|
||||||
|
2. **Different surfaces that happen to match** (hat bar + sidebar +
|
||||||
|
status bar are all "chrome frame") → create a shared primitive in the
|
||||||
|
palette/semantic layer (e.g. `bgChrome`) and give each surface its
|
||||||
|
own token that maps to that primitive. This lets themes diverge them
|
||||||
|
later without breaking widgets.
|
||||||
|
|
||||||
|
## Palette depth primitives
|
||||||
|
|
||||||
|
The palette layer has these depth primitives (defined in each theme YAML):
|
||||||
|
|
||||||
|
- `bg` (`#20202C`) — outermost root, behind everything
|
||||||
|
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
|
||||||
|
- `surface` (`#242838`) — elevated: pane headers, active tabs
|
||||||
|
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
|
||||||
|
|
||||||
|
Chrome tokens (`chromeBackground` / `chromeForeground` / `chromeBorder`)
|
||||||
|
are the shared root for all frame surfaces. They resolve to `bgSunken` /
|
||||||
|
`textDim` / `border` in the palette. Themes can override them to diverge
|
||||||
|
hat from sidebar from status bar if desired.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Three constants — never hardcode sizes or families:
|
||||||
|
|
||||||
|
```
|
||||||
|
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
|
||||||
|
family mono → clideMonoFamily (JetBrainsMono)
|
||||||
|
body size → clideFontBody (15)
|
||||||
|
caption size → clideFontCaption (14) — status bar, section headers, git info
|
||||||
|
mono size → clideFontMono (14) — terminal, code, paths, IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `ClideText` for themed text. Set `muted: true` for secondary text
|
||||||
|
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
|
||||||
|
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
|
||||||
|
|
||||||
|
## Extension-owned domain colors
|
||||||
|
|
||||||
|
Extensions that need domain-specific color coding (ticket types, decision
|
||||||
|
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||||
|
|
||||||
|
1. Create a color map class in the extension (e.g. `TicketTypeColors`).
|
||||||
|
2. Ship dark and light presets, auto-selected via
|
||||||
|
`ClideTheme.of(context).dark`.
|
||||||
|
3. Store user overrides under `ext.<id>.colors` in settings.
|
||||||
|
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`.
|
||||||
|
|
||||||
|
This keeps the core token surface lean and lets each extension own its
|
||||||
|
palette. The pattern scales to any extension needing domain colors.
|
||||||
|
|
||||||
|
## Named themes are user contracts — never retune their palette (D-69)
|
||||||
|
|
||||||
|
A bundled theme that ships under a recognisable name (`clide`, `midnight`,
|
||||||
|
`paper`, `terminal`, `catppuccin-*`, …) is a **user contract**. Its palette —
|
||||||
|
including syntax tokens, status colours, and `borderHi` — **MUST NOT be
|
||||||
|
retuned to pass a contrast gate**. Users pick Catppuccin because it looks like
|
||||||
|
Catppuccin; quietly darkening a swatch to clear WCAG changes what they signed
|
||||||
|
up for. Don't "fix" the artist's vision.
|
||||||
|
|
||||||
|
When a theme fails the contrast gate ([D-22](../../../governance/decisions/accessibility.md)),
|
||||||
|
the fix is **a sibling `-hc` (high-contrast) theme**, not an edit to the
|
||||||
|
faithful one (D-69):
|
||||||
|
|
||||||
|
- Keep `foo.yaml` pixel-faithful to the source palette.
|
||||||
|
- Add `foo-hc.yaml` — same silhouette, but muted text / status chips / syntax
|
||||||
|
tokens / focus border bumped to clear the strict (`extendedPairs`) gate. Only
|
||||||
|
`-hc`/`-cb` variants are held to the extended set; base themes pass the
|
||||||
|
baseline (`canonicalPairs`) only.
|
||||||
|
- Register both in `main.dart` `_loadBundledThemes` **and** the bundled list in
|
||||||
|
`test/a11y/contrast_test.dart`.
|
||||||
|
|
||||||
|
Mechanics: `canonicalPairs` (baseline, every theme) vs `extendedPairs`
|
||||||
|
(strict, `-hc`/`-cb` only) live in `lib/kernel/src/theme/contrast.dart`. If a
|
||||||
|
faithful palette is so soft it can't clear even the *baseline* chrome pairs,
|
||||||
|
that's a design call — surface it, don't silently retune. (Catppuccin Latte
|
||||||
|
hit exactly this on two chrome pairs.)
|
||||||
|
|
||||||
|
This is the same split VS Code ships: `Default Dark+` plus a separate
|
||||||
|
`Default High Contrast`.
|
||||||
|
|
||||||
|
## Where to look in the codebase
|
||||||
|
|
||||||
|
- Full token list: `lib/kernel/src/theme/tokens.dart`
|
||||||
|
- Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
|
||||||
|
- Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
|
||||||
|
- Decision: D-43 (handoff), D-44 (four bundled themes), D-45 (syntax tokens)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the Phosphor glyph reference page for the ui-design skill.
|
||||||
|
|
||||||
|
Mirrors `assets/fonts/phosphor/codepoints.csv` (the full bundled glyph set)
|
||||||
|
into a readable, greppable markdown table at
|
||||||
|
`.claude/skills/ui-design/references/phosphor-glyphs.md`, flagging which
|
||||||
|
glyphs clide already defines in `lib/widgets/src/icons/phosphor.dart`.
|
||||||
|
|
||||||
|
Run from the repo root: python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||||
|
CSV = ROOT / "assets/fonts/phosphor/codepoints.csv"
|
||||||
|
DART = ROOT / "lib/widgets/src/icons/phosphor.dart"
|
||||||
|
OUT = ROOT / ".claude/skills/ui-design/references/phosphor-glyphs.md"
|
||||||
|
|
||||||
|
# Codepoint -> camelCase accessor already defined in PhosphorIcons.
|
||||||
|
defined = {}
|
||||||
|
for m in re.finditer(r"static const (\w+) = PhosphorIconPainter\((0x[0-9a-fA-F]+)\)", DART.read_text()):
|
||||||
|
defined[int(m.group(2), 16)] = m.group(1)
|
||||||
|
|
||||||
|
rows = list(csv.DictReader(CSV.open()))
|
||||||
|
n_def = sum(1 for r in rows if int(r["codepoint"], 16) in defined)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"---",
|
||||||
|
"name: phosphor-glyphs",
|
||||||
|
"type: reference",
|
||||||
|
"tags: [icons, phosphor, ui-design]",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"# Phosphor glyphs — full reference (v2.0.8)",
|
||||||
|
"",
|
||||||
|
f"All **{len(rows)}** glyphs bundled in clide's Phosphor font "
|
||||||
|
"(`assets/fonts/phosphor/`, MIT). Generated from "
|
||||||
|
"`assets/fonts/phosphor/codepoints.csv` — **do not hand-edit**; regenerate with "
|
||||||
|
"`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py`.",
|
||||||
|
"",
|
||||||
|
f"The **In clide** column flags the **{n_def}** glyphs already wired into "
|
||||||
|
"`PhosphorIcons` (`lib/widgets/src/icons/phosphor.dart`) — reach for those first. "
|
||||||
|
"To use any other glyph, add a one-line `static const` to that class with the "
|
||||||
|
"codepoint below, then `ClideIcon(PhosphorIcons.<name>, size: 13)`. Keep additions "
|
||||||
|
"to icons we actually use — don't bulk-import.",
|
||||||
|
"",
|
||||||
|
"| Codepoint | Name (kebab) | Pascal | In clide |",
|
||||||
|
"|---|---|---|---|",
|
||||||
|
]
|
||||||
|
for r in rows:
|
||||||
|
cp = r["codepoint"]
|
||||||
|
accessor = defined.get(int(cp, 16))
|
||||||
|
in_clide = f"`PhosphorIcons.{accessor}`" if accessor else ""
|
||||||
|
lines.append(f"| `{cp}` | {r['name']} | {r['pascal_name']} | {in_clide} |")
|
||||||
|
|
||||||
|
OUT.write_text("\n".join(lines) + "\n")
|
||||||
|
print(f"wrote {OUT.relative_to(ROOT)} — {len(rows)} glyphs, {n_def} already defined")
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
name: whats-next
|
||||||
|
description: >
|
||||||
|
Surface the best batch of tickets to pick up next from pql. Walks the
|
||||||
|
initiative/epic tree, filters to unblocked work, refines context via
|
||||||
|
parallel agents (or `pql ticket refine` for empty descriptions), and
|
||||||
|
optionally activates the batch on a fresh branch. Use when the user
|
||||||
|
says "what's next", "next batch", "pick up work", or invokes
|
||||||
|
/whats-next. NOT triggered by "what should we work on" in a design
|
||||||
|
context — that's a discussion, not a batch selection.
|
||||||
|
user-invocable: true
|
||||||
|
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
|
||||||
|
---
|
||||||
|
|
||||||
|
# What's Next
|
||||||
|
|
||||||
|
Dependency-driven batch selection against pql. Three steps:
|
||||||
|
batch selection → refinement review → batch activation.
|
||||||
|
|
||||||
|
Pql is the single source of truth for tickets and decisions in this repo
|
||||||
|
(see [pql skill](../pql/SKILL.md) and [`decisions/README.md`](../../../decisions/README.md)). Always run from the repo root.
|
||||||
|
|
||||||
|
## Step 0: Sync state
|
||||||
|
|
||||||
|
Decisions on disk may be ahead of pql.db. Always sync before reading:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql decisions sync
|
||||||
|
```
|
||||||
|
|
||||||
|
If `pql` is missing, stop and tell the user — don't fall back to grep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Batch Selection
|
||||||
|
|
||||||
|
### 1a. Find active top-level work
|
||||||
|
|
||||||
|
Pql has no `milestone` concept; **initiatives** (and large **epics**) play
|
||||||
|
that role. List in-flight top-level work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket list --status in_progress --pretty
|
||||||
|
pql ticket list --status ready --pretty
|
||||||
|
```
|
||||||
|
|
||||||
|
If nothing is `in_progress` or `ready` at the initiative/epic level,
|
||||||
|
fall back to `pql plan status --pretty` for a dashboard read and ask the
|
||||||
|
user which area to advance.
|
||||||
|
|
||||||
|
### 1b. Build the work landscape
|
||||||
|
|
||||||
|
For each candidate epic or initiative, read its full descendant subtree
|
||||||
|
(nested, with the direct parent in `ancestors`) — one call, no client-side
|
||||||
|
flattening (pql ≥ 1.6.0):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket show <id> --tree --pretty # add --depth N to cap levels
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the old `--with-children` + hand-rolled status summaries.
|
||||||
|
|
||||||
|
### 1c. Filter to unblocked, actionable leaves
|
||||||
|
|
||||||
|
Don't walk blockers per ticket — pql does it. Compose the `list` filters
|
||||||
|
(pql ≥ 1.6.0): `--leaf` (no children), `--unblocked` (every blocker is
|
||||||
|
`done`/`cancelled`), and `--status` (since `--leaf`/`--unblocked` are
|
||||||
|
structure/blocker-state only and would otherwise include `done` leaves):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket list --under <epic-id> --leaf --unblocked --status backlog --pretty
|
||||||
|
```
|
||||||
|
|
||||||
|
`--status` takes a SINGLE value (not a comma list — `backlog,ready` matches
|
||||||
|
nothing and silently returns `[]`). The repo is almost all `backlog`; if a
|
||||||
|
team uses `ready`, run it a second time with `--status ready`.
|
||||||
|
|
||||||
|
That returns exactly the actionable, unblocked leaves under the epic — the
|
||||||
|
multi-result complement to `pql plan whatsnext` (which now also skips blocked
|
||||||
|
tickets, for the single best pick). Note `--unblocked` only means no *ticket*
|
||||||
|
blocker is open; a ticket "blocked on upstream" in prose (e.g. T-158) still
|
||||||
|
shows — read the description before batching.
|
||||||
|
|
||||||
|
### 1d. Rank and group
|
||||||
|
|
||||||
|
Rank unblocked tickets by:
|
||||||
|
|
||||||
|
1. **Priority** (critical > high > medium > low) — read from ticket fields.
|
||||||
|
2. **Epic proximity to done** — for each epic parent, compute
|
||||||
|
`done_children / total_children`. Higher ratio ranks higher: finishing
|
||||||
|
an epic unlocks downstream work and tightens the board.
|
||||||
|
3. **Fan-out** — tickets that unblock the most other tickets rank higher.
|
||||||
|
Approximate by scanning `pql ticket list --status backlog --pretty`
|
||||||
|
and counting how many list this ticket in their blockers (use
|
||||||
|
`--with-blockers` per candidate, or read `--jsonl` once and reduce in
|
||||||
|
memory).
|
||||||
|
|
||||||
|
Group into **epic-sized batches**: tickets sharing a `parent_id`, or a
|
||||||
|
logical cluster if no shared parent. If nothing groups naturally, batch
|
||||||
|
by area (the directory the work touches, e.g. `lib/src/pty/`).
|
||||||
|
|
||||||
|
### 1e. Show the board
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket board --pretty
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the "what's currently in flight" view — the user wants to see
|
||||||
|
WIP before committing to more.
|
||||||
|
|
||||||
|
### 1f. Present the recommended batch
|
||||||
|
|
||||||
|
Show the user:
|
||||||
|
|
||||||
|
- The recommended batch — IDs, titles, priorities, parent epic.
|
||||||
|
- **Why this batch** — which epic it advances, what it unblocks downstream.
|
||||||
|
- Current board state (WIP count vs. ready/backlog).
|
||||||
|
- One or two alternative batches worth considering.
|
||||||
|
|
||||||
|
Wait for user confirmation before Step 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Ticket Refinement Review
|
||||||
|
|
||||||
|
Two cases — handle the cheap one first.
|
||||||
|
|
||||||
|
### 2a. Tickets with empty descriptions → use pql
|
||||||
|
|
||||||
|
If any ticket in the batch has no description, hand off to pql's
|
||||||
|
built-in refinement flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket refine list --pretty
|
||||||
|
pql ticket refine next --pretty # full context for the next one
|
||||||
|
pql ticket refine write T-NN '{"description":"..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Walk these with the user (AskUserQuestion per ticket if appropriate)
|
||||||
|
before moving on.
|
||||||
|
|
||||||
|
### 2b. Tickets with descriptions → spawn refinement agents
|
||||||
|
|
||||||
|
For each ticket that already has a description but may still be
|
||||||
|
under-specified, spawn one agent in parallel. Use `general-purpose`
|
||||||
|
subagent_type (custom subagent_types lose SendMessage):
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent({
|
||||||
|
subagent_type: "general-purpose",
|
||||||
|
model: "sonnet",
|
||||||
|
description: "Refine T-NN context",
|
||||||
|
prompt: "You are the Refinement Manager for ticket T-NN.
|
||||||
|
|
||||||
|
Ticket: <title>
|
||||||
|
Description: <body>
|
||||||
|
Decision ref: <D-NN or Q-NN, if set>
|
||||||
|
|
||||||
|
Your job:
|
||||||
|
1. Run `pql decisions show <decision_ref> --with-refs --pretty` and
|
||||||
|
read the linked D/Q-record in decisions/<domain>.md.
|
||||||
|
2. Grep decisions/questions-*.md for related Q-records.
|
||||||
|
3. Verify referenced files, classes, and APIs actually exist in the
|
||||||
|
current tree (Read/Grep). Flag dangling references.
|
||||||
|
4. Cross-check against CLAUDE.md guardrails (single process, CLI-first,
|
||||||
|
own the rendering stack, etc.) — flag tickets that conflict.
|
||||||
|
|
||||||
|
Assess: does an implementer have enough context to proceed without
|
||||||
|
guessing? Report exactly one of:
|
||||||
|
- READY: <one-paragraph summary of what the implementer needs to know>
|
||||||
|
- GAPS: <list of specific ambiguities, each with 2–3 options>"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all agents in parallel (single message, multiple Agent tool calls).
|
||||||
|
|
||||||
|
### 2c. Resolve gaps
|
||||||
|
|
||||||
|
For each ticket that came back GAPS, surface ambiguities to the user
|
||||||
|
via AskUserQuestion. After the user resolves, append the resolution to
|
||||||
|
the ticket with `pql ticket append` (pql ≥ 1.6.0) — it appends
|
||||||
|
blank-line-separated WITHOUT round-tripping the existing description, so
|
||||||
|
there's no JSON-splicing or read-modify-write:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket append T-NN "Refinement (<date>): <resolution>"
|
||||||
|
# longer notes: pql ticket append T-NN --file note.md (or --stdin)
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use `append`, not `refine write` — the latter replaces the whole
|
||||||
|
description from a JSON patch and forces you to re-send the existing body.)
|
||||||
|
|
||||||
|
If a gap really requires a new D-record (architectural choice, not just
|
||||||
|
detail), flag it. Ask whether to write the D-record now (`pql decisions
|
||||||
|
claim D <domain> "title"` then author the markdown) or defer with a note
|
||||||
|
on the ticket.
|
||||||
|
|
||||||
|
### 2d. Present refined batch summary
|
||||||
|
|
||||||
|
Per ticket:
|
||||||
|
|
||||||
|
- READY summary, or the resolution the user just gave.
|
||||||
|
- Linked D/Q-records.
|
||||||
|
- Remaining blockers (should be none — re-check if Step 1 was a while ago).
|
||||||
|
|
||||||
|
Ask: "Batch ready. Activate?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Batch Activation
|
||||||
|
|
||||||
|
### 3a. Mark tickets in_progress
|
||||||
|
|
||||||
|
Batch transition (comma-separated IDs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql ticket status T-1,T-2,T-3 in_progress
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then persist it.** Ticket mutations (status here, and any `ticket new` in
|
||||||
|
Step 2) land only in the gitignored `.pql/pql.db`. The post-checkout/post-merge
|
||||||
|
hooks rebuild that DB from the committed changelog on every branch switch — so
|
||||||
|
un-exported changes vanish silently the next time anyone switches branches. After
|
||||||
|
creating or transitioning tickets, always:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql plan export # regenerates .pql/changelog/*.sql
|
||||||
|
git add .pql/changelog && git commit # durable; survives rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [pql skill](../pql/SKILL.md#versioning-planning-state--data-loss-footgun-read-this).
|
||||||
|
|
||||||
|
### 3b. Branch? Default no.
|
||||||
|
|
||||||
|
Solo-dev flow on this repo — work lands directly on `main` (see recent
|
||||||
|
`git log`). Don't create a topic branch unless the user explicitly asks.
|
||||||
|
If they do, plain `git checkout -b` is fine; there is no `gh` CLI.
|
||||||
|
|
||||||
|
### 3c. Spawn implementation agents (optional)
|
||||||
|
|
||||||
|
If the user wants agents driving the work, spawn `general-purpose`
|
||||||
|
subagents (`model: sonnet`) per ticket. Each prompt should include:
|
||||||
|
|
||||||
|
- Ticket details + the refinement summary from Step 2.
|
||||||
|
- The full content of any linked D-record (Read it and inline it — don't
|
||||||
|
just cite the ID; the agent has no project memory of it).
|
||||||
|
- Repo guardrails the work touches (from CLAUDE.md — quote the relevant
|
||||||
|
bullets, don't link).
|
||||||
|
- A RULES block: write files only, no git commits, no destructive ops,
|
||||||
|
message back when blocked or done.
|
||||||
|
|
||||||
|
### 3d. Report
|
||||||
|
|
||||||
|
End with a tight summary:
|
||||||
|
|
||||||
|
- Branch.
|
||||||
|
- Tickets now `in_progress`.
|
||||||
|
- Agents spawned (if any).
|
||||||
|
- Next step: implement, then commit per the [git-commit skill](../git-commit/SKILL.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Don't skip Step 0 — stale `pql.db` makes the rest of the skill lie.
|
||||||
|
- Don't leave ticket changes un-exported — `pql.db` is gitignored and the
|
||||||
|
post-checkout/post-merge hooks rebuild it from the committed changelog, so a
|
||||||
|
branch switch silently drops un-exported tickets. Always `pql plan export` +
|
||||||
|
commit `.pql/changelog/` after mutating tickets (Step 3a).
|
||||||
|
- Don't activate a batch the user hasn't confirmed.
|
||||||
|
- Don't spawn refinement agents for tickets that have no description — use
|
||||||
|
`pql ticket refine` instead; it's cheaper and writes back through the
|
||||||
|
proper channel.
|
||||||
|
- Don't reach for `gh` — this system doesn't have it. Plain `git` only.
|
||||||
|
- Don't `cd` into subdirectories — run everything from the repo root.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.pql/changelog/*.sql merge=union
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- run: (cd app && flutter pub get)
|
- run: (cd app && flutter pub get)
|
||||||
- run: ci/test.sh
|
- run: ci/test.sh
|
||||||
- run: ci/test_a11y.sh
|
- run: ci/test_a11y.sh
|
||||||
- run: ci/test_coverage.sh
|
- run: ci/coverage_gate.sh
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
name: integration_test (xvfb)
|
name: integration_test (xvfb)
|
||||||
@@ -69,3 +69,24 @@ jobs:
|
|||||||
- run: (cd app && flutter pub get)
|
- run: (cd app && flutter pub get)
|
||||||
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
||||||
- run: ci/test_e2e.sh
|
- run: ci/test_e2e.sh
|
||||||
|
|
||||||
|
docs:
|
||||||
|
name: dart doc (lib API)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with: { channel: stable, cache: true }
|
||||||
|
- run: dart pub get
|
||||||
|
- name: dart doc --validate-links (fail on warning)
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
dart doc --validate-links 2>&1 | tee dartdoc.log
|
||||||
|
if grep -q "^ warning:" dartdoc.log; then
|
||||||
|
echo "::error::dartdoc emitted warnings — see log above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dart-api-docs
|
||||||
|
path: doc/api/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout)
|
||||||
|
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pql: source .pql/hooks/post-merge (planning-state auto-import on pull)
|
||||||
|
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-merge"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pql: source .pql/hooks/post-rewrite (rebuild pql.db after rebase / amend)
|
||||||
|
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-rewrite"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pql: source .pql/hooks/pre-commit (planning-state auto-export)
|
||||||
|
. "$(git rev-parse --show-toplevel)/.pql/hooks/pre-commit"
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# -- macOS sandbox (machine-specific paths; template is committed) ------
|
# -- macOS sandbox (machine-specific paths; template is committed) ------
|
||||||
/macos/Runner/DebugProfile.entitlements
|
/macos/Runner/DebugProfile.entitlements
|
||||||
|
|
||||||
|
# -- clean-house skill run history / skip ledger (machine-local) --------
|
||||||
|
/governance/decisions/.clean-house-state.md
|
||||||
|
|
||||||
|
# -- frame0 wireframe local↔Frame0 id mapping (machine-local) -----------
|
||||||
|
**/*.idmap.json
|
||||||
|
|
||||||
# -- Flutter / Dart (single package at repo root) -----------------------
|
# -- Flutter / Dart (single package at repo root) -----------------------
|
||||||
/.dart_tool/
|
/.dart_tool/
|
||||||
/.packages
|
/.packages
|
||||||
@@ -26,13 +32,33 @@ tools/ui/test-results/
|
|||||||
tools/ui/playwright-report/
|
tools/ui/playwright-report/
|
||||||
tools/ui/.serve.pid
|
tools/ui/.serve.pid
|
||||||
|
|
||||||
# -- ptyc (C supporter tool) --------------------------------------------
|
|
||||||
/ptyc/bin/
|
|
||||||
/ptyc/*.o
|
|
||||||
|
|
||||||
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
||||||
/native/dugite/
|
/native/dugite/
|
||||||
|
|
||||||
|
# -- dart doc output (generated by `dart doc`, uploaded as CI artefact) -
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# -- coverage output (regenerated by every `flutter test --coverage`).
|
||||||
|
# Floor lives in pubspec.yaml `coverage_floor:`; nothing under
|
||||||
|
# coverage/ is committed.
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# -- Tee'd flutter-test output (see Makefile `make t T=...`) ---------
|
||||||
|
# Lives under test/ so the repo root stays uncluttered.
|
||||||
|
/test/.test-output/
|
||||||
|
|
||||||
|
# -- Build-time stamps generated by `make gen-build-info` -----------
|
||||||
|
# version (from pubspec.yaml), commit (git short SHA), date (UTC).
|
||||||
|
# Regenerated on every make build/run/test target.
|
||||||
|
/lib/src/build_info.g.dart
|
||||||
|
|
||||||
|
# -- Built C `clide` shell client (T-126). Source under ----------
|
||||||
|
# native/clide-cli/ stays in git; the per-platform compiled binary
|
||||||
|
# is built by `make clide-cli`.
|
||||||
|
/native/linux-x64/clide
|
||||||
|
/native/macos-arm64/clide
|
||||||
|
/native/macos-x64/clide
|
||||||
|
|
||||||
# -- Test, coverage, profile output ------------------------------------
|
# -- Test, coverage, profile output ------------------------------------
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
@@ -48,7 +74,7 @@ test/goldens/failures/
|
|||||||
|
|
||||||
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
||||||
/.pql/*
|
/.pql/*
|
||||||
!/.pql/pql-plan.json
|
!/.pql/changelog/
|
||||||
|
|
||||||
# -- SQLite index files (defensive; should never land at repo root) ----
|
# -- SQLite index files (defensive; should never land at repo root) ----
|
||||||
*.sqlite
|
*.sqlite
|
||||||
@@ -95,3 +121,19 @@ legacy/**/.coverage
|
|||||||
legacy/**/.coverage.*
|
legacy/**/.coverage.*
|
||||||
.clide/settings.yaml
|
.clide/settings.yaml
|
||||||
.claude/skills/pql/
|
.claude/skills/pql/
|
||||||
|
|
||||||
|
# frame0-wireframe local ID ↔ Frame0 ID mapping; per-machine state.
|
||||||
|
*.idmap.json
|
||||||
|
.pql/*
|
||||||
|
!.pql/changelog/
|
||||||
|
!.pql/pql-plan.json
|
||||||
|
|
||||||
|
# -- Local build / env artefacts (root-anchored; never tracked) ---------
|
||||||
|
/.coverage
|
||||||
|
/.venv/
|
||||||
|
/dist/
|
||||||
|
/logs/
|
||||||
|
/firebase-debug.log
|
||||||
|
# stray Python trees at the root (the real Dart suite is test/, singular)
|
||||||
|
/clide/
|
||||||
|
/tests/
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- Auto-generated by migrate-ids (D-26). CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15.
|
||||||
|
-- pql:created_by: migrate-ids
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM6KY9JVRKF22GGFMXR', '2026-05-18 11:59:28', '2026-05-18 11:59:28', NULL, 'ade2722e88d76b4ea6264545ff9b5398', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM78E2NAM4YK3A2AV6R', '2026-05-18 11:59:31', '2026-05-18 11:59:31', NULL, 'd996c0868b309fd7719ab4a8c2e0dd2b', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KY9JVRKF22GGFMXR', '06FB0TNQM78E2NAM4YK3A2AV6R', '2026-05-18 11:59:34', '2026-05-18 11:59:34', NULL, '05db5396eae627dbfae19415bf12802b', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM7J40S0A8Q8PC0PDY8', '2026-05-18 11:59:37', '2026-05-18 11:59:37', NULL, '44a606dc211dc172a0b7e45dca353555', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7J40S0A8Q8PC0PDY8', '06FB0TNQM6KEY6593FMW8Y40VC', '2026-05-18 11:59:41', '2026-05-18 11:59:41', NULL, 'a5958fcc4bc52bcba6c5d22119c1152a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM40XWGSN99DBDQDHJR', '2026-05-18 11:59:44', '2026-05-18 11:59:44', NULL, 'fd9d60cea050f1b2a8657664150e977c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78E2NAM4YK3A2AV6R', '06FB0TNQM40XWGSN99DBDQDHJR', '2026-05-18 11:59:47', '2026-05-18 11:59:47', NULL, '32e0aec7ec29068519dbd31c8a8cdc80', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM5MW2V3QCMWKMY4VQM', '2026-05-18 11:59:51', '2026-05-18 11:59:51', NULL, 'cd33e0846477b82b5cf7e00781783b8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78E2NAM4YK3A2AV6R', '06FB0TNQM5ZSRZARG7SXCC71NM', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, '4ce716dceb22197381bbf6f7f5cb339c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KEY6593FMW8Y40VC', '06FB0TNQM5ZSRZARG7SXCC71NM', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, '51d502ceb0a41091beb46513618b5b04', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KY9JVRKF22GGFMXR', '06FB0TNQM5ZSRZARG7SXCC71NM', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, '7f4410ea5c072aaac3317a9ed4a99912', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7J40S0A8Q8PC0PDY8', '06FB0TNQM5ZSRZARG7SXCC71NM', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, 'ccd6da9f9358922bc665133b792c65ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', '06FB0TNQM5ZSRZARG7SXCC71NM', '2026-05-18 11:59:54', '2026-05-18 11:59:54', NULL, 'd89ba28926b6472133096d779a20f079', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6SRMD12DMXCX6N7DM', '06FB0TNQM6E3X5WWRS09GCDGK8', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '129c875883b4e5a53cdc0683045ace03', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54N0ZDJRS7G6MP1XC', '06FB0TNQM6E3X5WWRS09GCDGK8', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '1b4d4dcf6933819d32df98dff4f89d83', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM653J6604KYDK6RT8R', '06FB0TNQM5AVSMKD15KBJYX48W', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '1c81cdedd045c46a2f1264bcd1067d11', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6E3X5WWRS09GCDGK8', '06FB0TNQM4V1PZ0EK6G60699EM', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '23b6fb946d9de5e730bd186a9ed350e0', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6E3X5WWRS09GCDGK8', '06FB0TNQM5AVSMKD15KBJYX48W', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '876ead25db98c91cadc5ea7caa65b513', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4EQAERS4J9X9RAETR', '06FB0TNQM4V1PZ0EK6G60699EM', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, '91e9aa2d0ffd1f32a52bb5cdf019f3fc', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM653J6604KYDK6RT8R', '06FB0TNQM5V9RNW2BGT5GHRTRG', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'a8c93af5185bec5604246916460fea08', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4EQAERS4J9X9RAETR', '06FB0TNQM653J6604KYDK6RT8R', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'f05aa1ae12d538345bc451d332395b9c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5KN50C7A723SEKZXG', '06FB0TNQM5V9RNW2BGT5GHRTRG', '2026-05-23 06:08:24', '2026-05-23 06:08:24', NULL, 'a0eacf85ca484d159a3dcc6c170f1760', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM77GWREHKA9K45P020', '06FB0TNQM6YSQYCE096R2SFRXG', '2026-05-23 09:52:40', '2026-05-23 09:52:40', NULL, 'bc99438a38a478190cae7e43c4342526', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM77GWREHKA9K45P020', '06FB0TNQM7BJS5E1JJGSJ9XHG0', '2026-05-23 09:52:43', '2026-05-23 09:52:43', NULL, '2a604702bb0023631d5a98810f695439', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM77GWREHKA9K45P020', '06FB0TNQM4XM8EY708NAM1JK00', '2026-05-23 09:52:45', '2026-05-23 09:52:45', NULL, '4303c0c52bfcc9f3dbd48f784ef1adf8', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AHSN3BXZFFWS0EPM', '06FB0TNQM78W48QX3JZP0QYAVW', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, '01db76f1732be386944e3da80fcdc0b4', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AHSN3BXZFFWS0EPM', '06FB0TNQM7G7MK8MP3BGQC36HG', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, 'c71b201e5cbe1f75ccda37657808e448', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AHSN3BXZFFWS0EPM', '06FB0TNQM7KA37YDBJ89GJ27H4', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, 'c9369a0c50aa82e2afa9a7fa70534b61', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AHSN3BXZFFWS0EPM', '06FB0TNQM48P8T23ZPR0BRK6PR', '2026-05-24 16:27:37', '2026-05-24 16:27:37', NULL, '7f6b0dddd7ba67b4db049f3d69b7c807', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48P8T23ZPR0BRK6PR', '06FB0TNQM654XV2TG4K0RVV1CW', '2026-05-24 16:27:43', '2026-05-24 16:27:43', NULL, 'ad5cfd3ff355cd44017ea0898ea14780', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48P8T23ZPR0BRK6PR', '06FB0TNQM6FMAHXM1XAHHCQC60', '2026-05-24 16:27:46', '2026-05-24 16:27:46', NULL, '7365404994faf148b33ab5ddc83fa01d', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM654XV2TG4K0RVV1CW', '06FB0TNQM6FMAHXM1XAHHCQC60', '2026-05-24 16:27:52', '2026-05-24 16:27:52', NULL, 'e3930f2673b82e4e6e0938450c522946', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48P8T23ZPR0BRK6PR', '06FB0TNQM71N3YMS82BAYD3M9R', '2026-05-24 16:27:56', '2026-05-24 16:27:56', NULL, '856fdd59d24643888245f06c621f40b8', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM654XV2TG4K0RVV1CW', '06FB0TNQM6V6TKRPA106D5SMMM', '2026-05-25 09:16:24', '2026-05-25 09:16:24', NULL, '329210f950241271814a8ed1255aa993', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48P8T23ZPR0BRK6PR', '06FB0TNQM67ET3VQP90WRXVSMG', '2026-05-25 09:16:24', '2026-05-25 09:16:24', NULL, '8a8027ce60a1ec2566c0b32f67f7759b', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W624VD4JW1GHDHAR', '06FB0TNQM7WE0SD9N4HCP9BB64', '2026-05-25 09:26:48', '2026-05-25 09:26:48', NULL, '388878e0c968776f9bc7a61934dbd3d6', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6YYG4X5R1GX97TJB4', '06FB0TNQM452XPSG99PA60ZZ48', '2026-05-31 18:01:19', '2026-05-31 18:01:19', NULL, '06723eeaf4ac4b3ca32e24df249de904', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM65R8CNFNE1MA5GGR0', '06FB0TNQM4PH1J6ENYT4PJZRHG', '2026-06-01 18:48:57', '2026-06-01 18:48:57', NULL, '1faf4292ceab6cfe3550dfedad554efe', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4PH1J6ENYT4PJZRHG', '06FB0TNQM5REFZXMY8ESWN0Z90', '2026-06-01 18:48:57', '2026-06-01 18:48:57', NULL, 'ca9731f8247e19ee44e99f51b4c33ba2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M83TTXHRXESFZY14', '06FB0TNQM5REFZXMY8ESWN0Z90', '2026-06-01 18:48:58', '2026-06-01 18:48:58', NULL, '097bef3723659e681584982688cb242f', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54WTNQDMYT0AVR4WM', '06FB0TNQM78J2H2S0R79SSSPW8', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, '112f7b091860de0dc7246f2c1b0eea45', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6Y5SET5WGSS2W78F8', '06FB0TNQM758PXMJH16PA3JHFW', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, '497eb0cb7db0b11194031874d6e8d89d', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7171NRB1T82TGP5RG', '06FB0TNQM78J2H2S0R79SSSPW8', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, '5102b2f239c5c18631997cbc1f2d998c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6EGSD0SB7NYNSP1BM', '06FB0TNQM758PXMJH16PA3JHFW', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, '9d0c7bbff8117a3569cf3b7937228184', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4C2PSN2GVJY9JDSZG', '06FB0TNQM7171NRB1T82TGP5RG', '2026-06-02 18:13:52', '2026-06-02 18:13:52', NULL, 'd935e3d2978a3584efb0767e75737e84', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45HHRBCRMV2166Y6M', '06FB0TNQM5YYMMKFD5Z8WG9F60', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '11d94aab7a635da1ffb610aa2ade2061', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM551VZ2NYV6PD0XQRC', '06FB0TNQM4XBRZJ6DEHFWREWC0', '2026-06-07 10:42:46', '2026-06-07 10:42:46', NULL, 'daa1e2dd097721230deca67500c1c00d', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM551VZ2NYV6PD0XQRC', '06FB0TNQM67PDXAFTPPW7B1WDR', '2026-06-07 10:42:50', '2026-06-07 10:42:50', NULL, '50bb0fcfafb5aacdd747bd9fcd6a3ea6', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB234WP4Y6Q16A0HFW8BSXMG', '2026-06-10 11:12:11', '2026-06-10 11:12:11', NULL, 'e0876fe73623c4063849a13f99d552f5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB234WP4Y6Q16A0HFW8BSXMG', '2026-06-10 11:12:11', '2026-06-10 11:12:14', NULL, 'cd39602b20a0681e3cca2dfee38d1734', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2ESCR4V6V07CRH18FBCDN8', '2026-06-10 11:12:18', '2026-06-10 11:12:18', NULL, 'c74a2c5d562157c24c11c8fa5d04799e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2ETJQP0CT6X7W3CWZ6NS9G', '2026-06-10 11:12:20', '2026-06-10 11:12:20', NULL, 'cb20764a77d2a15290ed61a186542095', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-10 11:12:25', '2026-06-10 11:12:25', NULL, 'dd9434426790edcaa556221f74c431ba', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2G1WD1839Z90AQ5C0BHNV4', '2026-06-10 11:17:25', '2026-06-10 11:17:25', NULL, '5535e3d16bee2a2cdfcbeb84ea3fca99', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- Auto-generated by migrate-ids (D-26). CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15.
|
||||||
|
-- pql:created_by: migrate-ids
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'status', 'backlog', 'ready', NULL, '2026-04-22 09:42:11', '2026-04-22 09:42:11', '2026-04-22 09:42:11', NULL, '3fba5e751d6a8c9a29335cad1550fa0f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'status', 'ready', 'in_progress', NULL, '2026-04-22 09:42:11', '2026-04-22 09:42:11', '2026-04-22 09:42:11', NULL, 'c4cb4ad45703aa943016359dc46469e8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'status', 'review', 'done', NULL, '2026-04-22 11:45:21', '2026-04-22 11:45:21', '2026-04-22 11:45:21', NULL, '146a823b003c92bcd8b8f7e74f2f9f39', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:45:21', '2026-04-22 11:45:21', '2026-04-22 11:45:21', NULL, 'bbf575b4892c07075dd436e44e65b2cc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KX7HEQJ65HZ566DG', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:46:12', '2026-04-22 11:46:12', '2026-04-22 11:46:12', NULL, 'f5088eafcad461a9a4ee4b4df88357d2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KX7HEQJ65HZ566DG', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:46:16', '2026-04-22 11:46:16', '2026-04-22 11:46:16', NULL, '556eba472e2de76accea58ee559ce7d2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KX7HEQJ65HZ566DG', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:46:26', '2026-04-22 11:46:26', '2026-04-22 11:46:26', NULL, '3a17808cf45fab2b987da0d0cbde98b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KX7HEQJ65HZ566DG', 'status', 'review', 'done', NULL, '2026-04-22 11:46:33', '2026-04-22 11:46:33', '2026-04-22 11:46:33', NULL, '2b09b099a97c35e26f35ac37228e9411', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:46:37', '2026-04-22 11:46:37', '2026-04-22 11:46:37', NULL, '0492e38c415358802970cb077447f7d7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:46:37', '2026-04-22 11:46:37', '2026-04-22 11:46:37', NULL, '1ed839ad770ebbffdf23364dbcb766eb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'status', 'review', 'done', NULL, '2026-04-22 11:46:37', '2026-04-22 11:46:37', '2026-04-22 11:46:37', NULL, '4f52a76829285f14e71363d162c600e3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:46:37', '2026-04-22 11:46:37', '2026-04-22 11:46:37', NULL, 'efda926413d09bbd05a1c88275fc927f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GSR5WPC2T7CJM8XW', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:46:44', '2026-04-22 11:46:44', '2026-04-22 11:46:44', NULL, '0c904f00c761b272e945045651b3c405', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GSR5WPC2T7CJM8XW', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:46:44', '2026-04-22 11:46:44', '2026-04-22 11:46:44', NULL, '0f7b405a869b6c6d17bddd2189c5b52f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GSR5WPC2T7CJM8XW', 'status', 'review', 'done', NULL, '2026-04-22 11:46:44', '2026-04-22 11:46:44', '2026-04-22 11:46:44', NULL, '46d21523224743c31eb97a3c1e18d2d9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GSR5WPC2T7CJM8XW', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:46:44', '2026-04-22 11:46:44', '2026-04-22 11:46:44', NULL, 'a33b066448c87f7be0542d61dfad5f66', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XZA5399D2EHYCVFC', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:46:48', '2026-04-22 11:46:48', '2026-04-22 11:46:48', NULL, '061a7151be46322c0128980c073356df', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XZA5399D2EHYCVFC', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:46:48', '2026-04-22 11:46:48', '2026-04-22 11:46:48', NULL, '08480dd43012f8f6f37e212767d26be2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XZA5399D2EHYCVFC', 'status', 'review', 'done', NULL, '2026-04-22 11:46:48', '2026-04-22 11:46:48', '2026-04-22 11:46:48', NULL, '7c004a225b6b82840101504aef4e758c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XZA5399D2EHYCVFC', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:46:48', '2026-04-22 11:46:48', '2026-04-22 11:46:48', NULL, '905c9d2df79619009b56009d193aa952', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7DV4ZS010XWZJ9A84', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:46:52', '2026-04-22 11:46:52', '2026-04-22 11:46:52', NULL, '9cafe6c3de8d9f5a3db8321ac7bcbafc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'status', 'review', 'done', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '11c89804420370461fbd8fe880758f7e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'status', 'review', 'done', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '36f16d47e4acf599d2d78933bcc03c77', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '4162b254ba6ddb4daa8e85e428c5608c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '45a71ef6bad527950f92d9323c3edf0a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '477dc88ff367b8a6bd393711a9dc934b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '5d92aa65816538906b0cc1ed5ee87ed3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '6b742f381bb7a072b02dfc55db62447f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'status', 'review', 'done', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '9842cfa577a4dc7eaf6792266bb365be', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'status', 'review', 'done', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, '9dd7f0c00d8c7e3c7a67abaa8412bec9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'c557f84b4744487609e7ffb02fd2ed49', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'cd7a68edc46c277c49fa5839990504ad', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'd387e7910739e3ecc6702b51f0d8b9e4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'e1d3d1a9ca033dd1d334d478c8802e32', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'status', 'in_progress', 'review', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'e4253f407304ff571299945fabe31eee', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'f22005f7ae58243f1fce0b07e6475235', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:47:11', '2026-04-22 11:47:11', '2026-04-22 11:47:11', NULL, 'feb8a00523b7118d285847f7d88e70d3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:52:59', '2026-04-22 11:52:59', '2026-04-22 11:52:59', NULL, '4bad0ff51702836a574ecb8afb5bd47e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:52:59', '2026-04-22 11:52:59', '2026-04-22 11:52:59', NULL, 'e64eeea1f1fcbea7c020191e6c94303d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7DV4ZS010XWZJ9A84', 'status', 'ready', 'in_progress', NULL, '2026-04-22 11:52:59', '2026-04-22 11:52:59', '2026-04-22 11:52:59', NULL, 'e767c59bd07f4ceb3dabbbbd9a8b25dc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:52:59', '2026-04-22 11:52:59', '2026-04-22 11:52:59', NULL, 'f32b33eaddf68e8213bfbe698960da29', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'status', 'backlog', 'ready', NULL, '2026-04-22 11:52:59', '2026-04-22 11:52:59', '2026-04-22 11:52:59', NULL, 'feb5c5a54317cafb1ca8d77ba93cc81e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'status', 'ready', 'in_progress', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, '08373a38596647969ebd74504544616a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'status', 'review', 'done', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, '3a7ee18edc398147113c0ab45b4812eb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'status', 'review', 'done', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, '5adac012531c1b1f400ecb2290782e5b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'status', 'in_progress', 'review', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, '7952677f8c48b6dc6e9c6e05a78e80a8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'status', 'ready', 'in_progress', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'a545724b00ae8c2f2b9ea09cb0a22eb0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'status', 'in_progress', 'review', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'ba68ce26a31c4bc0c56d5dcf1640b6e9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'status', 'ready', 'in_progress', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'c1a7e08caf95080e168df4e611dba0d7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'status', 'in_progress', 'review', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'c97eb916069dd8267e623872edcd7452', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'status', 'ready', 'in_progress', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'd520ce983ac294f25f96da3e22fe2257', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'status', 'in_progress', 'review', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'db6a409471d1fcf96494fca0cf36b950', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'status', 'review', 'done', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'eabc93b76de154cf79976608c0d00603', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'status', 'review', 'done', NULL, '2026-04-22 12:03:48', '2026-04-22 12:03:48', '2026-04-22 12:03:48', NULL, 'ed6fc64e88ca1dc9065e72ebd6067e4d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7DV4ZS010XWZJ9A84', 'status', 'in_progress', 'review', NULL, '2026-04-22 12:03:54', '2026-04-22 12:03:54', '2026-04-22 12:03:54', NULL, '11f427298c60d6f583daa698b0b54e37', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7DV4ZS010XWZJ9A84', 'status', 'review', 'done', NULL, '2026-04-22 12:03:54', '2026-04-22 12:03:54', '2026-04-22 12:03:54', NULL, '8a18fc5ed3fbf30e4abddd0cfe100bff', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'status', 'backlog', 'ready', NULL, '2026-04-22 14:16:39', '2026-04-22 14:16:39', '2026-04-22 14:16:39', NULL, '74a219dafd7350205cac9eeda53552c2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'status', 'backlog', 'ready', NULL, '2026-04-22 14:16:39', '2026-04-22 14:16:39', '2026-04-22 14:16:39', NULL, 'e7e1a901f6e799c0eae79c9dc2bb4a33', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'status', 'ready', 'in_progress', NULL, '2026-04-22 14:16:39', '2026-04-22 14:16:39', '2026-04-22 14:16:39', NULL, 'ec16cf36360eb2a7805bffed1a243ecf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'status', 'ready', 'in_progress', NULL, '2026-04-22 14:16:39', '2026-04-22 14:16:39', '2026-04-22 14:16:39', NULL, 'f49e07cca55f982a6a07162f55c0c6d8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'status', 'in_progress', 'review', NULL, '2026-04-22 14:30:05', '2026-04-22 14:30:05', '2026-04-22 14:30:05', NULL, '127e63f3c290f90e7c3aa847afd24923', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'status', 'review', 'done', NULL, '2026-04-22 14:30:05', '2026-04-22 14:30:05', '2026-04-22 14:30:05', NULL, '488736bebe779b1281384ce5765a883e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'status', 'in_progress', 'review', NULL, '2026-04-22 14:30:05', '2026-04-22 14:30:05', '2026-04-22 14:30:05', NULL, '76d283c661ddda877270447bf9ed3b0b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'status', 'review', 'done', NULL, '2026-04-22 14:30:05', '2026-04-22 14:30:05', '2026-04-22 14:30:05', NULL, '8108195e0776ff2e2493720ef80c5b6b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6PMW57C70KBJVAV10', 'status', 'backlog', 'cancelled', NULL, '2026-04-22 20:34:17', '2026-04-22 20:34:17', '2026-04-22 20:34:17', NULL, '66eac257c948b529c5949d17ca5f1d11', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4VVHNKNXPYD4XWRYW', 'status', 'backlog', 'ready', NULL, '2026-04-22 20:38:42', '2026-04-22 20:38:42', '2026-04-22 20:38:42', NULL, '2031b1358057b8fad868649bb7484500', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GHHRANK5532VJVJR', 'status', 'backlog', 'ready', NULL, '2026-04-22 20:38:42', '2026-04-22 20:38:42', '2026-04-22 20:38:42', NULL, 'a46451df6d3b67e3bcfbf48bb73c2e12', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM64DC28XV20JJ8KW00', 'status', 'backlog', 'ready', NULL, '2026-04-22 20:38:42', '2026-04-22 20:38:42', '2026-04-22 20:38:42', NULL, 'c397fe7de79cc82035f57a5cdaa2b83d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WXV1RKPDK810C3FR', 'status', 'backlog', 'ready', NULL, '2026-04-22 20:38:42', '2026-04-22 20:38:42', '2026-04-22 20:38:42', NULL, 'd471b281adf859311624bb19452d36ba', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WXV1RKPDK810C3FR', 'status', 'ready', 'in_progress', NULL, '2026-04-22 20:38:49', '2026-04-22 20:38:49', '2026-04-22 20:38:49', NULL, '154c84814ce1eacfd09580762e35748e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4VVHNKNXPYD4XWRYW', 'status', 'ready', 'in_progress', NULL, '2026-04-22 20:38:49', '2026-04-22 20:38:49', '2026-04-22 20:38:49', NULL, '2a3c5b0f2799072f0c593128ac99ddec', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM64DC28XV20JJ8KW00', 'status', 'ready', 'in_progress', NULL, '2026-04-22 20:38:49', '2026-04-22 20:38:49', '2026-04-22 20:38:49', NULL, 'e78fe6d9079f625573cf0993bb73a684', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GHHRANK5532VJVJR', 'status', 'ready', 'in_progress', NULL, '2026-04-22 20:38:49', '2026-04-22 20:38:49', '2026-04-22 20:38:49', NULL, 'ffade557a91afb2ce8d223a59c987d15', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GHHRANK5532VJVJR', 'status', 'in_progress', 'done', NULL, '2026-04-22 20:38:51', '2026-04-22 20:38:51', '2026-04-22 20:38:51', NULL, '0ad715f5bfdec4a3c8005f0f4cb857d6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4VVHNKNXPYD4XWRYW', 'status', 'in_progress', 'done', NULL, '2026-04-22 20:38:51', '2026-04-22 20:38:51', '2026-04-22 20:38:51', NULL, '4cd3af18c76884911ac0eff87f93e4c0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WXV1RKPDK810C3FR', 'status', 'in_progress', 'done', NULL, '2026-04-22 20:38:51', '2026-04-22 20:38:51', '2026-04-22 20:38:51', NULL, '870fda31d4fbd8c39e4a6ed78551a089', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM64DC28XV20JJ8KW00', 'status', 'in_progress', 'done', NULL, '2026-04-22 20:38:51', '2026-04-22 20:38:51', '2026-04-22 20:38:51', NULL, '92206091799f820dd2ae2778a92947fc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4005W4QV0ZPBMQ02C', 'status', 'in_progress', 'done', NULL, '2026-04-22 20:56:58', '2026-04-22 20:56:58', '2026-04-22 20:56:58', NULL, '0798cec4e8ce91238498218a90fc2b4e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4005W4QV0ZPBMQ02C', 'status', 'backlog', 'ready', NULL, '2026-04-22 20:56:58', '2026-04-22 20:56:58', '2026-04-22 20:56:58', NULL, '4d677f10ac70a801bc5d691e618dd098', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4005W4QV0ZPBMQ02C', 'status', 'ready', 'in_progress', NULL, '2026-04-22 20:56:58', '2026-04-22 20:56:58', '2026-04-22 20:56:58', NULL, '79fde318878ec91f2cd020be9db706c4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MKCHC37G69SJGQ04', 'status', 'backlog', 'ready', NULL, '2026-04-22 21:02:29', '2026-04-22 21:02:29', '2026-04-22 21:02:29', NULL, '615893bb4159bca57088c5a8e202e834', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MKCHC37G69SJGQ04', 'status', 'in_progress', 'done', NULL, '2026-04-22 21:02:29', '2026-04-22 21:02:29', '2026-04-22 21:02:29', NULL, '7350f6584bee9eeadb6a41001f34876b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MKCHC37G69SJGQ04', 'status', 'ready', 'in_progress', NULL, '2026-04-22 21:02:29', '2026-04-22 21:02:29', '2026-04-22 21:02:29', NULL, 'a4994cbb075a8921e4d0d505d922981c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KH7VR0BMZV5SSTXC', 'status', 'ready', 'in_progress', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '114825918ccc9039ad4653dc5c3b5785', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6JXWYYQ6MT6N8STYM', 'status', 'in_progress', 'done', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '1bc4adcc9db4ab023130198ed8299bb4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5Q6AWSTKGT0P64TRR', 'status', 'backlog', 'ready', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '2c6e445be22794878bd6784a5973c028', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KH7VR0BMZV5SSTXC', 'status', 'backlog', 'ready', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '307d5b39c6fe3df3d2a620a591d7d5c8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5Q6AWSTKGT0P64TRR', 'status', 'in_progress', 'done', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '798c86f77cdd2b3a1a337291215b45d6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6JXWYYQ6MT6N8STYM', 'status', 'backlog', 'ready', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '81141b1ca5d1d6ba526b33054189ebfa', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6JXWYYQ6MT6N8STYM', 'status', 'ready', 'in_progress', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '83e47e7c7550ce0f3c724edfa773016f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KH7VR0BMZV5SSTXC', 'status', 'in_progress', 'done', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, '8b6ef7ffc9b872edbe8ccd513f0a239e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5Q6AWSTKGT0P64TRR', 'status', 'ready', 'in_progress', NULL, '2026-04-22 21:16:41', '2026-04-22 21:16:41', '2026-04-22 21:16:41', NULL, 'c040aca52959ae8febda5e0dd54252a2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '007710b30c9e75b030b44dba9fa5f824', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'parent_id', NULL, 'T-2', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '0c80d9ff14fd5992b92f0c9b8b9eb647', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'parent_id', NULL, 'T-2', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '18e5b09f781d974d4746fa984734872f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'parent_id', NULL, 'T-7', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '1fbe35313558f6277b818cff665009a9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '20d5c46249f5b8810e17914929ec763e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM699HAWC76SHRA7AM4', 'parent_id', NULL, 'T-4', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '251327841ab9971717b36c2cb79ca299', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '2e3916450f98a766aac0c2abc301e6c4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'parent_id', NULL, 'T-4', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '4502258c81a7e75eafd3631e0cdf90b8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67ZXPWX71ND4C699C', 'parent_id', NULL, 'T-3', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '4c190ec1739ed8ec68fe694687fa5fe3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'parent_id', NULL, 'T-7', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '4ca23526443704936c352c2842f2f7f8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5KYAV8TMH3Y3FPRSR', 'parent_id', NULL, 'T-4', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '50dc074f7f06d51e11fee8d00dd7dd74', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'parent_id', NULL, 'T-2', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '6a700a972bd1c1a0729bf27078def56f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5Q6AWSTKGT0P64TRR', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '773e27abfec30eeb016b441c1dc0d8f8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '881064c3f6f63638a754929f1078b011', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '9404bff31ca5c310db47f5c0e11c90ca', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48MDE8ZZ82VWNY994', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '96cf4f78cd06636178ba9a64d6b83f2d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'parent_id', NULL, 'T-2', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '9b17afe084710d3e198c7435ee0fb4b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '9ca8eb0c58afc81c07a7538b8e504ad4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66TK8TQ1X5T9GK6V0', 'parent_id', NULL, 'T-5', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, '9f69eaa0855cbea3a8e715b7bb917624', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6PMW57C70KBJVAV10', 'parent_id', NULL, 'T-4', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'b683b61fdb656a5f6df871190736771f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KH7VR0BMZV5SSTXC', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'c27a712580912bc125013ca9ee3330a1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4005W4QV0ZPBMQ02C', 'parent_id', NULL, 'T-4', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'cca49092a5f8f1912acd35c168101148', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4M63G18VAQFG6R3XW', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'ce9352bab1e00b12f8ff794fca067732', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'dc8745c0d7b3445b0c78985f1f9995dc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'e1831cbdaa2e3ce0000a590874c08a76', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6JXWYYQ6MT6N8STYM', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'e5214fde5b73b4a7165107dc85057ec5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'parent_id', NULL, 'T-2', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'e6649095b63a9a991df2db00a7ad53ca', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W194B2421P2SF83R', 'parent_id', NULL, 'T-8', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'ee6e9785f9f760ab2c6b3f689afefad2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5QHMB46NSHHPVQRF0', 'parent_id', NULL, 'T-3', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'f9b20bf38afe42bfdfd811ab282658ee', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'parent_id', NULL, 'T-6', NULL, '2026-04-23 10:23:49', '2026-04-23 10:23:49', '2026-04-23 10:23:49', NULL, 'fb24ffd20df49eb6febd496a6c1c02bf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:01:08', '2026-04-24 07:01:08', '2026-04-24 07:01:08', NULL, '728ad0916172fb20a1c4b19d0753e4db', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:01:08', '2026-04-24 07:01:08', '2026-04-24 07:01:08', NULL, 'bfd785a9e40e27979d287ec137ec01d1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:01:48', '2026-04-24 07:01:48', '2026-04-24 07:01:48', NULL, '3b1ea2037fa8d26c4293a2bd5d0fa007', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:01:48', '2026-04-24 07:01:48', '2026-04-24 07:01:48', NULL, 'd5329907e937e55e00a9d002da5ac6d4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:05:40', '2026-04-24 07:05:40', '2026-04-24 07:05:40', NULL, '5a372aa4814812aa716d853550fea046', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:05:50', '2026-04-24 07:05:50', '2026-04-24 07:05:50', NULL, 'ba35f95ff2763f26ebccff21a2b826af', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:05:51', '2026-04-24 07:05:51', '2026-04-24 07:05:51', NULL, '4e46180f02c86278deedd9f8f08d4d07', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:06:30', '2026-04-24 07:06:30', '2026-04-24 07:06:30', NULL, '8e985069c3c5eeb6b172f83430458810', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:06:32', '2026-04-24 07:06:32', '2026-04-24 07:06:32', NULL, '66c14397b8af87bceaace31b1dff2853', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'review', 'done', NULL, '2026-04-24 07:07:55', '2026-04-24 07:07:55', '2026-04-24 07:07:55', NULL, '5733cc23693ca40190bd8c5063a7a022', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'in_progress', 'review', NULL, '2026-04-24 07:07:55', '2026-04-24 07:07:55', '2026-04-24 07:07:55', NULL, '9669c3ab67cb4477961d2ee3aa799a90', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'ready', 'in_progress', NULL, '2026-04-24 07:07:55', '2026-04-24 07:07:55', '2026-04-24 07:07:55', NULL, 'de6f0dd22f5f0e664793f74429322b8b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:07:55', '2026-04-24 07:07:55', '2026-04-24 07:07:55', NULL, 'e7b5b2d4e43d09ede3d177528718683a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'done', 'in_progress', NULL, '2026-04-24 07:08:00', '2026-04-24 07:08:00', '2026-04-24 07:08:00', NULL, '976f8fecced9b5322ed3403b8fbdf0dc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'in_progress', 'done', NULL, '2026-04-24 07:08:29', '2026-04-24 07:08:29', '2026-04-24 07:08:29', NULL, '281fe02b5f9467e50e0a186fc1afb77c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'done', 'in_progress', NULL, '2026-04-24 07:08:29', '2026-04-24 07:08:29', '2026-04-24 07:08:29', NULL, 'fd27037a9f94cf0df10016ff2c3b6e78', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'in_progress', 'ready', NULL, '2026-04-24 07:08:35', '2026-04-24 07:08:35', '2026-04-24 07:08:35', NULL, '52ed48f7463c234efeb35fcf45957e8a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:08:35', '2026-04-24 07:08:35', '2026-04-24 07:08:35', NULL, '8fb22a82912e93b9abff0da33d371093', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'backlog', 'cancelled', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, '3e65f78d186b37308165679295b40408', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'cancelled', 'backlog', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, '5b81f1c8048cd2b3ef1055ac9b04f958', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'ready', 'backlog', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, '7348283db8af7104b475d0d84a0a7157', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'in_progress', 'ready', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, '804f233159ce47a2c213778971fc00a1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'backlog', 'ready', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, '91171fb011294517240bd33360b08119', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'ready', 'in_progress', NULL, '2026-04-24 07:08:59', '2026-04-24 07:08:59', '2026-04-24 07:08:59', NULL, 'd0b87332faf5a67e7e6d36ff9c981c00', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'backlog', 'done', NULL, '2026-04-24 07:30:15', '2026-04-24 07:30:15', '2026-04-24 07:30:15', NULL, 'bf086ebf2d4e6b0a592f8386e916115b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'status', 'done', 'backlog', NULL, '2026-04-24 07:30:22', '2026-04-24 07:30:22', '2026-04-24 07:30:22', NULL, '8e29783cc997d80b6699bd14537e8613', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'backlog', NULL, '2026-04-24 08:08:16', '2026-04-24 08:08:16', '2026-04-24 08:08:16', NULL, '1bc6c7c959742697cd8670771ebeb8d9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'backlog', 'ready', NULL, '2026-04-24 08:08:16', '2026-04-24 08:08:16', '2026-04-24 08:08:16', NULL, '51a117e3e688d86d72e33a844dfb6217', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'in_progress', NULL, '2026-04-24 08:08:23', '2026-04-24 08:08:23', '2026-04-24 08:08:23', NULL, 'bf1f3a13e6bc7df6adf20b545e6911aa', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'in_progress', 'ready', NULL, '2026-04-24 08:08:38', '2026-04-24 08:08:38', '2026-04-24 08:08:38', NULL, '66d52940767ba7c91f848431ee618ba1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'in_progress', NULL, '2026-04-24 08:08:40', '2026-04-24 08:08:40', '2026-04-24 08:08:40', NULL, '1154f797d2c87e3e5aff6c8407b97f7e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'in_progress', 'review', NULL, '2026-04-24 08:08:44', '2026-04-24 08:08:44', '2026-04-24 08:08:44', NULL, '99a8c33d31f16025ad3a27305ec703a0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'review', 'in_progress', NULL, '2026-04-24 08:08:48', '2026-04-24 08:08:48', '2026-04-24 08:08:48', NULL, '9475c2583b2f0d3742f792f5dcffd4aa', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'in_progress', 'ready', NULL, '2026-04-24 08:16:15', '2026-04-24 08:16:15', '2026-04-24 08:16:15', NULL, 'df08122765cc76b0003b6e9ff54426fb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'backlog', NULL, '2026-04-24 08:16:19', '2026-04-24 08:16:19', '2026-04-24 08:16:19', NULL, '4e273f400ffcfbf2bc926ee4e9575fbc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'backlog', 'ready', NULL, '2026-04-24 08:16:21', '2026-04-24 08:16:21', '2026-04-24 08:16:21', NULL, '6e750d5ebacc51f2734e94675322e5c4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'status', 'ready', 'in_progress', NULL, '2026-04-24 08:16:47', '2026-04-24 08:16:47', '2026-04-24 08:16:47', NULL, '9e82a53ad5b5d58037280a6778e335fe', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4M63G18VAQFG6R3XW', 'status', 'backlog', 'in_progress', NULL, '2026-04-24 08:16:56', '2026-04-24 08:16:56', '2026-04-24 08:16:56', NULL, '8a9247ea1de65f4696dc7af64a5d2b7f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67ZXPWX71ND4C699C', 'status', 'backlog', 'ready', NULL, '2026-04-24 08:17:11', '2026-04-24 08:17:11', '2026-04-24 08:17:11', NULL, '38b265daee03a4df5fb525e48b477388', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- Auto-generated by migrate-ids (D-26). CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15.
|
||||||
|
-- pql:created_by: migrate-ids
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67ZXPWX71ND4C699C', 'T-43', '2026-04-22 22:01:13', '2026-05-07 19:04:55', NULL, '069521bcf068ff6889cee5251511446c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7SS2V5Z1M1XJGQX0M', 'T-86', '2026-05-06 13:28:58', '2026-05-07 19:04:55', NULL, '07e51e80b6df55240c465d17a9430700', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7P6Q0DG4RG4CBHFJG', 'T-67', '2026-04-24 06:34:16', '2026-05-07 19:04:55', NULL, '0b64bd846488ccf4fce74a6e14629edb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM460GRBMZMV8E5339M', 'T-77', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, '0cdd07e0992ab8eb76d6945ae1079855', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79D19C0ZTXAVPKCCG', 'T-12', '2026-04-22 11:47:03', '2026-05-07 19:04:55', NULL, '10055ea4ae6c926286eb81eb35b8ef8f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427FX3GTR3VNNKM40', 'T-63', '2026-04-24 06:34:16', '2026-05-07 19:04:55', NULL, '178d61b166cf7e8f17efc83cdf60c062', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7DV4ZS010XWZJ9A84', 'T-6', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, '17e7bcab28adfbab47be2ad9aa4e4c56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W194B2421P2SF83R', 'T-25', '2026-04-22 20:14:24', '2026-05-07 19:04:55', NULL, '1b888a483ec795737d7a1cf71fdbd699', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM77477EN2R5872G95M', 'T-93', '2026-05-07 06:33:47', '2026-05-07 19:04:55', NULL, '1f5f4e360943d2a903cdd7c819bfeaa3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7D8AG5AY1CW58HCB4', 'T-58', '2026-04-23 20:32:06', '2026-05-07 19:04:55', NULL, '253f8fb1458c5394eea856c885f94fff', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4M63G18VAQFG6R3XW', 'T-28', '2026-04-22 20:14:52', '2026-05-07 19:04:55', NULL, '280276cb372707c0fcdbc7de5c68f568', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEP425DNV3W7RFPM', 'T-72', '2026-05-05 12:53:05', '2026-05-07 19:04:55', NULL, '2c7cd24dee2e7d21a31f476af233284d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XZA5399D2EHYCVFC', 'T-5', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, '31b2ad13bb8bcc41e4d21825a232f086', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79CNBXJ2S3CFQR7VM', 'T-81', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, '32698f648acf611d10fc719f6be28bad', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM76XDA3SZMPWR8WQSC', 'T-62', '2026-04-24 06:34:16', '2026-05-07 19:04:55', NULL, '3439d016919d87e8b4860735a23de1a8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GSR5WPC2T7CJM8XW', 'T-4', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, '407e3921ca9e6d5d13e11b7f577ad591', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4PMYC2E1X99HX4MDM', 'T-84', '2026-05-06 10:16:02', '2026-05-07 19:04:55', NULL, '40ce052d36bd0270aa01f9e25c1a944a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5A8DYZ3BCJN1DEY7C', 'T-71', '2026-05-05 12:53:01', '2026-05-07 19:04:55', NULL, '43cfd9109375b0fb6a8f176a0df764a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48MDE8ZZ82VWNY994', 'T-27', '2026-04-22 20:14:45', '2026-05-07 19:04:55', NULL, '440474d801f1b9bd05e8fbb4254c3817', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M1VZ8Z2SZ94F0EQR', 'T-3', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, '44d2e24e49ca8555a6b3327f7e954ede', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7AVABFH067XGTTCVM', 'T-76', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, '4a3e92a591238f629d18f9b10087c7f6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WXV1RKPDK810C3FR', 'T-31', '2026-04-22 20:33:56', '2026-05-07 19:04:55', NULL, '4c423ea86e8173f7fa6edddea9adbadb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62E3MNW8TBTKSR9GM', 'T-1', '2026-04-22 09:41:51', '2026-05-07 19:04:55', NULL, '55239329122e1f84e9aeac9cee14a21a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KH7VR0BMZV5SSTXC', 'T-37', '2026-04-22 21:03:40', '2026-05-07 19:04:55', NULL, '55c80e1ce022b96aab6e97e31579c302', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42RQZVEWTF9CJSRF8', 'T-85', '2026-05-06 13:28:58', '2026-05-07 19:04:55', NULL, '6133918ee124e5502286fa0de06b5bd9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7QREFG3SBG2QPYY7R', 'T-59', '2026-04-23 20:32:06', '2026-05-07 19:04:55', NULL, '64c389eab795606b9d8b5b65877a67bc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JH7WV2RH5DGEA2AC', 'T-19', '2026-04-22 14:08:40', '2026-05-07 19:04:55', NULL, '65ba2e99100be29287532b3390054401', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'T-26', '2026-04-22 20:14:33', '2026-05-07 19:04:55', NULL, '685c38f471288372eab298a106a06d66', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6T6580D8ABDVTMNZW', 'T-8', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, '6aa0a08f23546d11b37f80d54920c3be', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4VVHNKNXPYD4XWRYW', 'T-30', '2026-04-22 20:33:53', '2026-05-07 19:04:55', NULL, '6d929d2dcc64310a43b21be1a8314258', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TQEYESK545T8F164', 'T-57', '2026-04-23 20:32:06', '2026-05-07 19:04:55', NULL, '6ff368c440149011af6cc3e2bdfe243f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79H4MPEWW2TWQ89D0', 'T-46', '2026-04-23 20:27:58', '2026-05-07 19:04:55', NULL, '71f030d09b588292801ae8853accd90c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM70QNVQY1MQGP3A33G', 'T-78', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, '73ecd48d2d9f1cdf8e1bf6e88a8dc812', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM52JT96V5067SM0DMR', 'T-87', '2026-05-06 13:46:07', '2026-05-07 19:04:55', NULL, '740c1dcab17dbdc339750aa8e32a1281', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'T-45', '2026-04-23 20:27:28', '2026-05-07 19:04:55', NULL, '7c312190b902337039b21f4743be8908', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5Q6AWSTKGT0P64TRR', 'T-39', '2026-04-22 21:03:40', '2026-05-07 19:04:55', NULL, '825042c21d032181c1fe6d55a29a94f9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59BAJSJ1JESDTPNER', 'T-15', '2026-04-22 11:47:18', '2026-05-07 19:04:55', NULL, '836e6112c6e1dff23772619499fad245', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7TW8EJNJ2VP306A20', 'T-14', '2026-04-22 11:47:18', '2026-05-07 19:04:55', NULL, '8496d923db0e1fa76e7b787a273e974e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5QHMB46NSHHPVQRF0', 'T-24', '2026-04-22 14:08:40', '2026-05-07 19:04:55', NULL, '8a892f3717f8da2c93f67d3170af014f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5AACHWPFWZDCJ3CPW', 'T-73', '2026-05-05 12:53:21', '2026-05-07 19:04:55', NULL, '8e532488915724b80881c7b2dc227de9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P00AT6KGVZ1EF60C', 'T-18', '2026-04-22 13:26:29', '2026-05-07 19:04:55', NULL, '900d00f28a29207a464017a0bd750f33', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'T-80', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, '90da3a2c5bd03b314d661362a3919506', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6JXWYYQ6MT6N8STYM', 'T-38', '2026-04-22 21:03:40', '2026-05-07 19:04:55', NULL, '91c3ef42d94e16789915ee6366f7015d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM75XRV8AQG2HEQZEJ0', 'T-16', '2026-04-22 11:47:18', '2026-05-07 19:04:55', NULL, '98bf61f1693e8b8877c2cc03c9fb120d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'T-41', '2026-04-22 21:03:40', '2026-05-07 19:04:55', NULL, '9ab8f6ffa933c87dd3153eaea452e902', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66FTCTWHH9AQTNFKR', 'T-55', '2026-04-23 20:32:06', '2026-05-07 19:04:55', NULL, 'a7b7dc2af0426c8bcbfea6a3023a0aff', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7YHYTWJGWPFJNHEAR', 'T-10', '2026-04-22 11:47:02', '2026-05-07 19:04:55', NULL, 'a8fa7598a624ad83878db046ffa8986f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TS48DVFDVJNZ5WGC', 'T-11', '2026-04-22 11:47:02', '2026-05-07 19:04:55', NULL, 'af42494291be5910520becc10285e157', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R0WRHSJV1MVCW31M', 'T-9', '2026-04-22 11:47:02', '2026-05-07 19:04:55', NULL, 'b8d2fca6e4b3882e4cd1ec486924265b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4005W4QV0ZPBMQ02C', 'T-35', '2026-04-22 20:34:06', '2026-05-07 19:04:55', NULL, 'bbbe9cce785ce60416cba671cea2985a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50DVPFP4WY74RQBB8', 'T-61', '2026-04-24 06:34:16', '2026-05-07 19:04:55', NULL, 'bbf53c20ed761296c7bd54d418b67218', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'T-60', '2026-04-23 20:32:06', '2026-05-07 19:04:55', NULL, 'bc1502a8c8fe4e44c004f56e9e15b03e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6PMW57C70KBJVAV10', 'T-22', '2026-04-22 14:08:40', '2026-05-07 19:04:55', NULL, 'be0ae61f02897da33e8a9b2d6d1b002e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GHHRANK5532VJVJR', 'T-34', '2026-04-22 20:34:03', '2026-05-07 19:04:55', NULL, 'c042cabf5814a596e1582a8a85138543', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM606BMYDDGRSJBVPG8', 'T-70', '2026-05-05 12:52:56', '2026-05-07 19:04:55', NULL, 'c04d59ecdcc1ade801ed7f0866a3f00c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM74S58G8XZV1766VD0', 'T-90', '2026-05-06 20:38:14', '2026-05-07 19:04:55', NULL, 'c36ec6ce6ac423fa9983758304139d2b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7CEKQCMZAV751402G', 'T-47', '2026-04-23 20:28:43', '2026-05-07 19:04:55', NULL, 'c5abd99aac954b783b84f3b77d003691', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5EP8B65T0AR52TQFW', 'T-92', '2026-05-07 05:51:48', '2026-05-07 19:04:55', NULL, 'c62020a1cf82eb7ed6bb5635d5f8f0fa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QHBTA4VE6TWQT43R', 'T-13', '2026-04-22 11:47:18', '2026-05-07 19:04:55', NULL, 'd08af226653f0822b332b640dfcedde5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KX7HEQJ65HZ566DG', 'T-2', '2026-04-22 11:45:26', '2026-05-07 19:04:55', NULL, 'd1b1abb9046e230f581009daf61e7bfd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7Y0DFP66HSP073XHC', 'T-88', '2026-05-06 14:34:35', '2026-05-07 19:04:55', NULL, 'd1ea1e789ff487787076d0ca66670e35', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JZ2AKN74FKMK3MR8', 'T-69', '2026-05-05 12:52:52', '2026-05-07 19:04:55', NULL, 'd2622779cfbab03758ed02949f12fa72', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MKCHC37G69SJGQ04', 'T-32', '2026-04-22 20:33:58', '2026-05-07 19:04:55', NULL, 'd4125c549e70d4f4d37bb43a4f3ccbd1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NS39QCDHNVVVT1R4', 'T-17', '2026-04-22 13:26:29', '2026-05-07 19:04:55', NULL, 'd732affde22ee738f13bacb127c094e7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5X2K66A0MQY2VZ7H4', 'T-83', '2026-05-06 09:17:39', '2026-05-07 19:04:55', NULL, 'da553a2c61adb1795c0ab33867dc6ba3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4Q4XW93E3WA1SD9KW', 'T-75', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, 'db0b7c934e54d06fcf33aaf168a723e4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6YBQP75NQQ4Y9DN9C', 'T-68', '2026-05-05 12:52:48', '2026-05-07 19:04:55', NULL, 'e14345ea70ff466ed3d9b8de161ea556', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6VMWWSP4BV8Q11W0M', 'T-20', '2026-04-22 14:08:40', '2026-05-07 19:04:55', NULL, 'ebf54efaf812fb838959a80b4cdcc527', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM699HAWC76SHRA7AM4', 'T-21', '2026-04-22 14:08:40', '2026-05-07 19:04:55', NULL, 'f217b0ca72240fbbe463d296f250f3b6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM64DC28XV20JJ8KW00', 'T-33', '2026-04-22 20:34:01', '2026-05-07 19:04:55', NULL, 'f7924a89ac74721c9145a511b7a1b4e9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM454GCK8ENRKRA44NW', 'T-49', '2026-04-23 20:31:35', '2026-05-07 19:04:55', NULL, 'f9fb75eb241d8edd62688d7ac3fdd25a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM57AEVK5E0GWVPGAY8', 'T-79', '2026-05-05 12:58:59', '2026-05-07 19:04:55', NULL, 'fe4c71ca925995617dcd4409caaae9d0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'T-40', '2026-04-22 21:03:40', '2026-05-07 19:04:55', NULL, 'fe57a623da7c3fda11455f1d78ba7f4c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7162Z26FDWXG35VH0', 'T-7', '2026-04-22 11:45:41', '2026-05-07 19:04:55', NULL, 'ffa3d6a93f30550da97a3357a0b40c3c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48X0763Z9DZSM0HFR', 'T-94', '2026-05-07 19:08:29', '2026-05-07 19:08:52', NULL, '8087c73fd647b2156a0275ec7ec2ffcd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WCDTR41VN9P1EGMC', 'T-96', '2026-05-17 17:16:01', '2026-05-17 18:11:18', NULL, 'c44d6bedd77f53875b4fb7f92e8c9895', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F3V130BQXQH6HDVC', 'T-98', '2026-05-17 18:47:13', '2026-05-17 18:55:57', NULL, 'b2543702670de76b92f98bcb3690901e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7NRKR9J1QTDSP9650', 'T-101', '2026-05-17 18:47:29', '2026-05-17 19:01:39', NULL, '8acbd73697594b2f0686c7e1f01f0b8e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QNZM15V1ZK32YS6G', 'T-102', '2026-05-17 18:47:33', '2026-05-17 19:01:39', NULL, 'c77e3fe23ababa8854065860aaaf7645', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM493QAZNJTJQHYFGCC', 'T-103', '2026-05-17 18:47:39', '2026-05-17 19:18:43', NULL, '34fae6a3452e2f3a6fc76605fb82cd85', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6MP9JD93ZE0CDGFFW', 'T-106', '2026-05-17 18:47:51', '2026-05-17 19:18:43', NULL, '52a5c071f2c90428eba13aaa8f785831', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7WW3008NZRJWJ88HG', 'T-113', '2026-05-17 18:48:24', '2026-05-17 19:18:43', NULL, 'fabd5c2006ae0e13714b1aa12ecd2e41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6MWAKNB2NE3ZZ0XKW', 'T-110', '2026-05-17 18:48:09', '2026-05-17 19:39:12', NULL, '22d75188f931b18801e8e9015e9863a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'T-66', '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '8b24910aba2e49638668b03f64006a5a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'T-64', '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '9d3e8f627b3095e1db58e047618be710', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM65SSVGN5M36T4QNDC', 'T-117', '2026-05-17 19:28:28', '2026-05-17 19:39:40', NULL, '852007cdda3b405757c088e160798b40', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4CWANNSKNF2X5JEJ4', 'T-100', '2026-05-17 18:47:23', '2026-05-17 19:47:50', NULL, '3edd0e73ac89116392d4aab4e8180c6a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM63H3CXCKB13E3RNHW', 'T-105', '2026-05-17 18:47:47', '2026-05-17 19:56:28', NULL, '0f673c39dc8c450947f34c86e743f8cc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM40PV7G53JMHHNYPYG', 'T-108', '2026-05-17 18:47:59', '2026-05-17 20:05:38', NULL, 'f023344206a267eefd5d2c453d3483c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4ZNR15BSANVX07N1R', 'T-112', '2026-05-17 18:48:20', '2026-05-17 20:11:16', NULL, '874a9bbe234acb55dfc20d72a2c5a361', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W20PVMHX1BBJ4TVW', 'T-118', '2026-05-17 20:51:47', '2026-05-17 20:53:41', NULL, 'b8fbc53c7092e7b29a869f0247cd0e38', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7FG92QKNFJSCAP888', 'T-114', '2026-05-17 18:48:28', '2026-05-18 07:04:38', NULL, '2c16b22992624d00be14801c105350c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7Y9KHNVRZ41VYW1YM', 'T-111', '2026-05-17 18:48:14', '2026-05-18 07:43:45', NULL, '1c12b7ea3d82ff2bc1e5680690ee0746', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5W67PPRVA8SAJXHFG', 'T-104', '2026-05-17 18:47:42', '2026-05-18 07:53:10', NULL, 'ec883c703bad46f757d38057c71336d3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TTAC53CQYWDQ91H0', 'T-109', '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, 'ed187720e362af1ff3f8cae3fd0ac828', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45DS16DX0NKMN9Q3G', 'T-121', '2026-05-18 08:07:23', '2026-05-18 08:22:19', NULL, 'ec1cbea6674c6fb5d365510587c082ec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM614M7ES54V3SETAQM', 'T-115', '2026-05-17 19:10:21', '2026-05-18 09:30:59', NULL, '640c8f8e5a67cdb44eaabd65bf21fc04', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4RJ9K19P5AX14RHRR', 'T-123', '2026-05-18 10:29:02', '2026-05-18 10:29:02', NULL, '255468e265f300a234e8456d3a0abf51', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7FXS713CKYDZ284NC', 'T-107', '2026-05-17 18:47:55', '2026-05-18 10:30:30', NULL, '4bc77250919cd4cef2fb1bfd3438a7c6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4H5C2Z6GCBCQ85QGR', 'T-116', '2026-05-17 19:14:31', '2026-05-18 11:22:46', NULL, '972bf9cfde6de9ff37cb5cb1dcc5a2dd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM53N56Z60B4SQ5VTRG', 'T-74', '2026-05-05 12:53:22', '2026-05-18 11:51:40', NULL, '7b71c20018634be865ab843900b93eff', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AV8YXV4NYWS2ZQYW', 'T-124', '2026-05-18 11:58:47', '2026-05-18 12:51:08', NULL, 'b272f7b62db06897d71bcee86b0aef6e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KY9JVRKF22GGFMXR', 'T-125', '2026-05-18 11:58:52', '2026-05-18 15:51:09', NULL, '801eba2049b8dc046ab81f731d0caef1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78E2NAM4YK3A2AV6R', 'T-126', '2026-05-18 11:58:57', '2026-05-18 16:04:29', NULL, '6bc6a6b1f009225521ace82a6616244c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7J40S0A8Q8PC0PDY8', 'T-127', '2026-05-18 11:59:02', '2026-05-19 10:03:26', NULL, '0e2e8e90772e11cfad16ee7805a58f7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KEY6593FMW8Y40VC', 'T-128', '2026-05-18 11:59:06', '2026-05-19 10:06:46', NULL, '9cf26209ff598d91824d59f791b34938', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM40XWGSN99DBDQDHJR', 'T-129', '2026-05-18 11:59:11', '2026-05-19 12:19:51', NULL, '618bff52db127ed5655c78c4e09f3078', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5MW2V3QCMWKMY4VQM', 'T-130', '2026-05-18 11:59:17', '2026-05-19 12:33:52', NULL, 'ab53ef037d31eb5a977cf6fe3280e126', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5ZSRZARG7SXCC71NM', 'T-131', '2026-05-18 11:59:23', '2026-05-19 13:14:41', NULL, '7339bc2840f028e45e798e8bd8c9c337', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM76Q97D8R01CRTTA9R', 'T-99', '2026-05-17 18:47:18', '2026-05-19 13:14:41', NULL, 'ff7b3f5bbb802d081c305d32b3330163', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54DENVAR46RXCCQQM', 'T-119', '2026-05-18 07:43:50', '2026-05-20 07:00:06', NULL, 'd2732a32ffbc29ddca7f9eacad2d5570', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM47ARMYK2H24KJ0DAG', 'T-120', '2026-05-18 07:53:08', '2026-05-20 16:02:35', NULL, 'fdab02033af4c3f6df800ddf3bc78198', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM56CF9W94FSFDSBKGW', 'T-97', '2026-05-17 18:47:08', '2026-05-20 16:02:46', NULL, '84c961d85f80b8ba21fc227f7d191948', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6TNXTJCQBH9VADC4M', 'T-89', '2026-05-06 20:36:42', '2026-05-22 13:37:46', NULL, '7197034866b3116490b8633fd18ff01f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4HGK6KR0314P2M32M', 'T-91', '2026-05-06 20:49:47', '2026-05-22 13:37:46', NULL, 'c1d096207b720c77ac05b1950dce1bcd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54N0ZDJRS7G6MP1XC', 'T-136', '2026-05-22 15:59:34', '2026-05-22 17:37:23', NULL, '24eae70edc48f10e227710ee77e94fb6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54G017Y29APWJTCN0', 'T-133', '2026-05-22 15:50:06', '2026-05-22 17:37:23', NULL, 'a4886b596be68f26bcf01315f2eba159', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6SRMD12DMXCX6N7DM', 'T-135', '2026-05-22 15:59:34', '2026-05-22 17:37:23', NULL, 'e18b6b13a86b6202afb93b4bae32c824', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4EQAERS4J9X9RAETR', 'T-134', '2026-05-22 15:59:34', '2026-05-22 17:55:11', NULL, '99fdef2c143698cd8dee33e5903015f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6E3X5WWRS09GCDGK8', 'T-137', '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '0ae6c36aab9db692afc7b9de9d83b70b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4V1PZ0EK6G60699EM', 'T-138', '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '006e8c34ed9058294d4144ed2fd853ba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427ZFSTWJ21EBK5FG', 'T-142', '2026-05-22 22:06:59', '2026-05-22 22:18:02', NULL, '93352b90d5b8532dca21609a5e6dee2c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6YWAC8SSGQBK31T98', 'T-143', '2026-05-22 22:31:22', '2026-05-22 22:36:09', NULL, 'd585cc1215d301735c9e207e08193e6e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BV0CXWCT04Z81EG0', 'T-144', '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, 'b8e257b9abde9da0c38fc15cb7d4ee41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM653J6604KYDK6RT8R', 'T-139', '2026-05-22 15:59:34', '2026-05-22 23:16:28', NULL, 'e43a27d4464ce09179016a65dc702a57', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5AVSMKD15KBJYX48W', 'T-140', '2026-05-22 15:59:34', '2026-05-22 23:31:46', NULL, '2e962cad8956dff339a9c3028ff8c83a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM558PCHET90BBSHGTC', 'T-146', '2026-05-23 06:19:51', '2026-05-23 06:30:47', NULL, 'd575cf325d7b347a88ca11f516b1b715', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM47FEEMHPE8J5VVBKM', 'T-147', '2026-05-23 07:03:18', '2026-05-23 07:06:54', NULL, '2b5e6b5f67000bea0097ea86986124b6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4GDS1R8H2TW12WX7G', 'T-149', '2026-05-23 07:14:36', '2026-05-23 07:27:32', NULL, '1ffa4dc9299a04293bfa473036ed8e08', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5KN50C7A723SEKZXG', 'T-145', '2026-05-23 06:07:59', '2026-05-23 08:43:19', NULL, 'e688de6397028cc53baef88f4af4db5c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6F9XA7F2BX9P18BR4', 'T-150', '2026-05-23 09:04:40', '2026-05-23 09:33:03', NULL, '6103463406709c57456e06226486a739', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM77GWREHKA9K45P020', 'T-151', '2026-05-23 09:52:14', '2026-05-23 10:20:12', NULL, '1ee4e62532962562e7a57316eedf0d04', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7BJS5E1JJGSJ9XHG0', 'T-153', '2026-05-23 09:52:27', '2026-05-23 10:35:56', NULL, '872e6c5e27120a76b24f7c033286071e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6YSQYCE096R2SFRXG', 'T-152', '2026-05-23 09:52:20', '2026-05-23 10:43:52', NULL, '9ba6f625fd14bb95a31902b699e821e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5B47G9GG22N9KBNEG', 'T-156', '2026-05-23 11:15:08', '2026-05-23 12:05:19', NULL, '35180e547e94880bbe7c9f73b8b28e2c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XM8EY708NAM1JK00', 'T-154', '2026-05-23 09:52:32', '2026-05-23 14:50:17', NULL, '45a2e199fca3eb06e286f98d5c8c2228', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5R9Q3T7CBMTHM2GFR', 'T-155', '2026-05-23 11:06:50', '2026-05-23 14:50:17', NULL, '578a38cb9982309cac40cd0a214d7839', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6CYT7NW4WE8ZP1TXR', 'T-148', '2026-05-23 07:03:18', '2026-05-23 15:05:29', NULL, 'edd29e766c59c65f2ca3c83700ed96f0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5V9RNW2BGT5GHRTRG', 'T-141', '2026-05-22 15:59:34', '2026-05-23 20:49:44', NULL, 'c95d953ec43d43dde07d94192342c3a9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QZ3JJF1704AV5JCW', 'T-157', '2026-05-23 20:48:31', '2026-05-23 20:57:07', NULL, '96ab8af2d5b2835a37c23b1af6c25be1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6KD2DKTGHC6CF1MJ4', 'T-159', '2026-05-24 08:53:13', '2026-05-24 08:54:30', NULL, '6d88f03a4c6268aefb7ab51411e6970f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7SPRJZ519RZK1ZPAW', 'T-161', '2026-05-24 09:24:02', '2026-05-24 09:45:07', NULL, 'e5ad416566fa75f3caf1ccf085aa2506', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5932T486Z1F1M6X6W', 'T-173', '2026-05-24 16:45:40', '2026-05-24 17:10:07', NULL, '0d564a0dc8adc327e3f9460c254499fa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AHSN3BXZFFWS0EPM', 'T-165', '2026-05-24 16:26:15', '2026-05-25 06:29:25', NULL, '879dcc70eadbc3d29a1e6f81ee0e86d4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7KA37YDBJ89GJ27H4', 'T-166', '2026-05-24 16:26:23', '2026-05-25 06:31:08', NULL, '6a6a0ba97d6331ac3441060ec28197db', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7K30Y9WAYZ3D330YR', 'T-175', '2026-05-25 07:11:09', '2026-05-25 07:25:02', NULL, '1d7a94fb62083c7c4390103f5bf29786', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RP0Q312AZJ8S1Y6R', 'T-177', '2026-05-25 07:11:22', '2026-05-25 07:25:02', NULL, '59a180929bd8894b53a761b2dda7b4f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7AXEWDKD4VQNQCACM', 'T-176', '2026-05-25 07:11:16', '2026-05-25 07:27:57', NULL, '231d303368c603a6e01a6da02802fbc4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4VNVEAEYBX3S9Z8Q8', 'T-178', '2026-05-25 08:10:10', '2026-05-25 08:21:46', NULL, '3f7aae66c9e487bef74fd4d93b304d12', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4DTGYPAB29MRDN000', 'T-179', '2026-05-25 08:10:18', '2026-05-25 08:26:21', NULL, '826c15b06fe0aaaf660e31042fe371bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48P8T23ZPR0BRK6PR', 'T-169', '2026-05-24 16:26:49', '2026-05-25 12:12:29', NULL, '99d40776974ced8e57efaa135df3ea9f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM654XV2TG4K0RVV1CW', 'T-170', '2026-05-24 16:27:01', '2026-05-25 19:38:16', NULL, 'd4a19feb40528dd6d22186c442c46890', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4R8YY69YAARCVE4AC', 'T-160', '2026-05-24 09:13:42', '2026-05-30 11:05:56', NULL, '87a62aa99c4c3e3be11c031c5667ff68', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7G7MK8MP3BGQC36HG', 'T-167', '2026-05-24 16:26:30', '2026-05-30 11:05:56', NULL, 'a7343c529fc0c766f4d7c2a9530614fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78W48QX3JZP0QYAVW', 'T-168', '2026-05-24 16:26:39', '2026-05-30 11:44:28', NULL, '65bd7ae87632b0d65777a26af0bb31f2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6FMAHXM1XAHHCQC60', 'T-171', '2026-05-24 16:27:09', '2026-05-30 21:11:26', NULL, '556df973d835841f7e8ae0c8cd9dd310', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7ZB63YYNFXM9DQNRR', 'T-174', '2026-05-24 17:11:17', '2026-05-30 21:11:26', NULL, 'db290b95db28db6b5a90d880d8a36fdb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM71N3YMS82BAYD3M9R', 'T-172', '2026-05-24 16:27:16', '2026-05-30 21:34:56', NULL, '8fca1700a4bf028e071cf30e845ee8f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6V6TKRPA106D5SMMM', 'T-180', '2026-05-25 09:15:58', '2026-05-31 08:49:07', NULL, '412af567b67756da2e6b438c6a4bfd45', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7WE0SD9N4HCP9BB64', 'T-183', '2026-05-25 09:26:40', '2026-05-31 09:30:11', NULL, 'f09d4e58d36e3049bbcf5d92df301530', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM55DZN2RFEVP6CG48G', 'T-184', '2026-05-30 11:42:53', '2026-05-31 10:17:09', NULL, '7f2e958614fa540d1087d3f1c84568df', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM52PRNJM86A1DZTQ1C', 'T-185', '2026-05-30 21:34:26', '2026-05-31 10:22:02', NULL, 'd7d14700bc8fb0164e6e2852b7a358cd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5HM3N07VB3RPFJCFM', 'T-164', '2026-05-24 16:26:05', '2026-05-31 10:22:03', NULL, '8bc4ad2dbf63176d9674cd014c6cce7b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TDSA2PDCV814E3YW', 'T-95', '2026-05-08 10:48:59', '2026-05-31 11:20:21', NULL, '628386f31fcfb4e96eac7b503ab2eb4b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5316HVGDYD2W0DC50', 'T-162', '2026-05-24 09:29:07', '2026-05-31 14:17:22', NULL, '46d071266f94b404760832f3c2d88927', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM514YJBF8AY8Q4N7Q4', 'T-188', '2026-05-31 11:38:28', '2026-05-31 14:17:22', NULL, '7328143d32f30fc412c22b5d76de92a9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4GVWHMRTMQYRTRKD4', 'T-187', '2026-05-31 11:38:20', '2026-05-31 14:17:22', NULL, 'ce42be5c4b71e4c155b26e22506045f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4DAQ2CEFESG9JGMZ8', 'T-192', '2026-05-31 12:08:42', '2026-05-31 14:46:39', NULL, '6bd69db546e3d3aadb77b5c289db817a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6S7FDAXQX32JNCSFG', 'T-186', '2026-05-31 11:37:34', '2026-05-31 16:40:19', NULL, 'eed0473280a7c582289f0fcaa6bf6dd5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM709YPWECH1E66XXD4', 'T-193', '2026-05-31 16:12:42', '2026-05-31 17:26:25', NULL, 'ffbdde83a1cd6bbe9046f686705b2e15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6YYG4X5R1GX97TJB4', 'T-52', '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, '1e34fa00e15b0ada663e117c7493ca69', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QPGVP54EKJPCNQQM', 'T-51', '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, 'cc6cf69d394f87a29c685b27ec13155e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM452XPSG99PA60ZZ48', 'T-53', '2026-04-23 20:32:06', '2026-05-31 19:41:13', NULL, '22e98aa4226d3e9979abd7b7cdf7b68a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7MFNNCC0MMY2TCNRM', 'T-194', '2026-06-01 06:49:25', '2026-06-01 06:49:47', NULL, 'c4ed6eb84383fb6052a61f94e37c98c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4M2Z9YZ0SA81E5Q08', 'T-195', '2026-06-01 06:56:05', '2026-06-01 07:08:16', NULL, '6d199187494f2f942598092f9743285f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6NTYAB8K6T50F8W00', 'T-196', '2026-06-01 08:58:10', '2026-06-01 10:45:14', NULL, '57fadff50bee964e4d821507d0e6e79b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6HQXNNF05GXWK7BWM', 'T-198', '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'f203d6089bcb5f23d81e4c3cf67b3d33', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM68APBCQTMGJAZ2B9C', 'T-199', '2026-06-01 11:38:32', '2026-06-01 12:35:04', NULL, '6fda1ce6b9f1ea55f001f1840fef1f7a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4RAKSX59W1HJCZ1M8', 'T-200', '2026-06-01 14:04:00', '2026-06-01 14:04:24', NULL, 'b2c666f841d2546d0e6f150dec9e901b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM57GY6PTG78SD3961M', 'T-201', '2026-06-01 14:25:59', '2026-06-01 14:45:39', NULL, '289f9f811f27bad8b3f01acda2651b4b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7G8QBJ2PNQ0VREN24', 'T-197', '2026-06-01 08:58:19', '2026-06-01 16:36:38', NULL, 'a29f2ef4fa8bc86eceff40c324d613d0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5A3984KFPD4NKRG6M', 'T-202', '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '726f989a61da913e9f41f3353e453541', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM643F7Z648KNFPT7GR', 'T-203', '2026-06-01 16:48:31', '2026-06-01 16:48:56', NULL, '736af2dbfdf6cfbc8d2683c8e0feb063', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM65R8CNFNE1MA5GGR0', 'T-204', '2026-06-01 18:48:19', '2026-06-01 18:51:08', NULL, '7685b5bd09a25bb7094a7458f292e81b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6M83TTXHRXESFZY14', 'T-207', '2026-06-01 18:48:47', '2026-06-01 18:59:19', NULL, '0fd6c0e9537080a0b8ab85f351161542', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4PH1J6ENYT4PJZRHG', 'T-205', '2026-06-01 18:48:27', '2026-06-01 19:12:01', NULL, 'e874c6bf6c97dae25404d6f1044f9c53', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5REFZXMY8ESWN0Z90', 'T-206', '2026-06-01 18:48:40', '2026-06-01 19:27:18', NULL, 'cdf5b34234372c3de0ef0c3caecabed2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4C2PSN2GVJY9JDSZG', 'T-210', '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, '1281485b320f6c146f8af2519c0c02f3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48QJV4N7J62436GC0', 'T-213', '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, 'ac6cf78658e8a9ddfee7573e125377ed', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7171NRB1T82TGP5RG', 'T-211', '2026-06-02 18:13:52', '2026-06-03 09:00:37', NULL, 'bf74e45672e065ef2aa21ce85c341bb0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM54WTNQDMYT0AVR4WM', 'T-224', '2026-06-02 18:13:52', '2026-06-03 09:05:50', NULL, '16525f2ae7035719b319a6f579d5e316', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TP6KXTB1GHKBW1G4', 'T-215', '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '4fcb599f4b489ac8050770416737295d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7WYF8ESBSGAQF1S48', 'T-217', '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, '682a13f3d6e396600a5d51d38e9508d7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM51CNEJP52P79RDVJW', 'T-216', '2026-06-02 18:13:52', '2026-06-03 09:18:28', NULL, 'e89cf8d8a6c65f6c47ef73aa0f1a8d7d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78J2H2S0R79SSSPW8', 'T-214', '2026-06-02 18:13:52', '2026-06-03 09:18:38', NULL, '32ca757e5489e190af0dae4297b200d7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XZQJAK31WRVQ0HGW', 'T-228', '2026-06-03 09:49:35', '2026-06-03 10:02:02', NULL, 'ce3d4e957ec8cf656f8f022e518bf8d0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7SA9TEX5CNMTG67MW', 'T-163', '2026-05-24 09:42:44', '2026-06-03 10:05:55', NULL, 'abed11805979ce7f3d996679ad7007a4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5PFAYE4XH7WV6VDW8', 'T-227', '2026-06-03 09:48:23', '2026-06-03 10:09:26', NULL, '99368eee1c22ea3cf782b52159c04dc9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM68T660K07YH3X5DH0', 'T-229', '2026-06-03 09:51:28', '2026-06-03 10:09:26', NULL, '9d77e30754feb77e0a64a4aa5d47f668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6Y5SET5WGSS2W78F8', 'T-219', '2026-06-02 18:13:52', '2026-06-03 10:31:10', NULL, 'fae2d0c2950c6f4050c70e7c3c07a525', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6EGSD0SB7NYNSP1BM', 'T-220', '2026-06-02 18:13:52', '2026-06-03 10:59:25', NULL, '3c812d20f5da80c0b936cb59edd2704f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67ET3VQP90WRXVSMG', 'T-181', '2026-05-25 09:16:05', '2026-06-03 11:41:17', NULL, '045b971616418029b63a850050eef8a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM758PXMJH16PA3JHFW', 'T-221', '2026-06-02 18:13:52', '2026-06-03 11:50:02', NULL, '9e906306ef56b2c22724d0b72f14ce2d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7AMEYEBTF8YGJ4BVG', 'T-218', '2026-06-02 18:13:52', '2026-06-03 11:50:08', NULL, '9c2a9480a37df7681fe4388e27345573', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GJRWZVHZF24TPPS4', 'T-226', '2026-06-03 09:37:54', '2026-06-03 11:50:23', NULL, '6bf4765285f29d4c8fce8d71472bae0c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM427BVZYN6MD7ZZX74', 'T-231', '2026-06-03 13:17:32', '2026-06-03 13:30:05', NULL, 'd6eb4f7458f0d1c72432b87a23aa60e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5BJCK5YG3R0NEEF70', 'T-232', '2026-06-03 13:29:49', '2026-06-03 13:41:42', NULL, 'f255d56c3872bf6a210734e127a4a4bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JR6QZDMKE167C4MR', 'T-82', '2026-05-06 08:30:15', '2026-06-03 14:56:07', NULL, 'd2669b112125f4f76041885296f5d315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM492DTRZJK9CGQPGDC', 'T-234', '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '92a7cabc855b76f429217cc65fcfa308', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5GH6VP8TZA95NMYK0', 'T-230', '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, 'eec8da81c7180e8f301196514ba54e3b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM46ANXPW02XGD3D3NG', 'T-237', '2026-06-03 15:43:45', '2026-06-03 21:29:47', NULL, 'f9b1b279c9292d892ac07b7f4ea7cef8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W624VD4JW1GHDHAR', 'T-182', '2026-05-25 09:26:31', '2026-06-04 12:00:09', NULL, '35ccb60111678fbc43644460a31f1d61', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM56FKPV4WSPWA0KMB4', 'T-189', '2026-05-31 11:38:36', '2026-06-04 12:00:09', NULL, '3759a2e27d13bddbcc19ed9a4fffef9a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5V7GHH42RHV04DPTR', 'T-65', '2026-04-24 06:34:16', '2026-06-04 12:00:09', NULL, '544a078f04d5c926a47cbfdacb4e3e2f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5BWXW61EVYGMZD47G', 'T-191', '2026-05-31 11:58:19', '2026-06-04 12:00:09', NULL, 'd186513cc0783306b2c4e1b2e789dff7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5ABRPK2SPW8VY7RP4', 'T-190', '2026-05-31 11:38:42', '2026-06-04 12:00:09', NULL, 'fac1c8383ac95413e8885f8ee64b0d54', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM41S6RRTMTY9TYZA50', 'T-122', '2026-05-18 09:06:59', '2026-06-05 13:08:05', NULL, '600101f09173e61159a24ed44e34dd71', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66TK8TQ1X5T9GK6V0', 'T-42', '2026-04-22 21:03:40', '2026-06-05 15:17:09', NULL, '223a9820685b3da2a6a6e19cf6ad036b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5WT3BHB8JZY77G5AW', 'T-243', '2026-06-05 15:14:25', '2026-06-05 15:17:09', NULL, 'd82988ff85cd644376a13f49e406138f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KPN4VK4EZP013NNG', 'T-244', '2026-06-05 15:21:10', '2026-06-05 15:24:35', NULL, 'f375c96bfd96b9cdd89db0cd046318f3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YYY4FVPX9KRVHCMC', 'T-50', '2026-04-23 20:31:48', '2026-06-05 21:12:23', NULL, '581605b67cbab29fdcddbac46154308a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66DNEK5FS9C0T4J5R', 'T-245', '2026-06-05 21:20:00', '2026-06-05 21:24:52', NULL, '577902ab47810d0f848f48c8bf9b3196', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6DH7C6GQN9WNKQNXR', 'T-246', '2026-06-05 21:28:44', '2026-06-05 21:29:50', NULL, '4ce06c5b0b8a09d452934ae8ba1e5c0b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6QCYT7JBF352YVJ48', 'T-247', '2026-06-05 21:28:48', '2026-06-05 21:30:04', NULL, 'c2fbcdc7eda67938f8d35f64a92782b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5F4D2Q363QF7V40K8', 'T-239', '2026-06-03 21:41:58', '2026-06-06 06:44:40', NULL, '7312cd1c56d3c8d60625df7712a7deb4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5F2GC0ABFFVAH34EC', 'T-238', '2026-06-03 21:11:40', '2026-06-06 07:24:00', NULL, 'a724281049b28271d21c87776313dd72', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7ZQNDVPAPFSD9KYS4', 'T-251', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '9f010efd2f1b44840ff0af4d3723a4af', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BVE5EYM92CWGSXA4', 'T-250', '2026-06-06 07:26:50', '2026-06-06 07:49:08', NULL, 'a8c1c3f7d308c4fe005fb998b7ec6730', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7JXD3FKW91934D0W0', 'T-249', '2026-06-06 07:21:17', '2026-06-06 08:21:40', NULL, '9f43580f953cac1a27dab6e0ac2deece', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM70TW38E78FFXDHYAC', 'T-248', '2026-06-06 07:21:17', '2026-06-06 08:31:25', NULL, '577fda356a95641dd39c28a716227d1a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5NEXC0P46NJHACNM0', 'T-233', '2026-06-03 13:29:59', '2026-06-06 09:20:02', NULL, '4cea7def08ae19b77ecfdd04b11f4b5a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM78Q3VYWSE830CTVB4', 'T-212', '2026-06-02 18:13:52', '2026-06-06 10:02:03', NULL, '75bc38afe04d24ff77c2b55d9b764fe1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QYYKHPQ03H3QD61W', 'T-209', '2026-06-02 18:13:52', '2026-06-06 10:02:06', NULL, '4f782ec2f1fb76eea268247cf4cd75a0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4K2AAPK52Y8BEJ3YR', 'T-257', '2026-06-06 14:01:03', '2026-06-06 18:58:25', NULL, 'd79886881baf9d88ff31ec2970da2712', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5KYAV8TMH3Y3FPRSR', 'T-23', '2026-04-22 14:08:40', '2026-06-06 19:06:30', NULL, '9b456adde02b184d5d46652aac210af9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM72ACQSDBJTKRBC58W', 'T-223', '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, '7909a9620aa2a05d6a02b36f030f3641', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM620F5ENRYNT9S0JDW', 'T-222', '2026-06-02 18:13:52', '2026-06-06 19:24:44', NULL, 'b58b0643ed78d32df526e765a076b2eb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5DYC7CRP8TMHADW8W', 'T-225', '2026-06-02 18:13:52', '2026-06-06 19:37:47', NULL, '6d2670282a9f66635c2fccaa20162209', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XMQ44AVCAARQZX14', 'T-208', '2026-06-02 18:13:52', '2026-06-06 19:53:07', NULL, '015f7fe6ec6e3b348a6157401742472b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6S7KHYPE9W7S1JHZ8', 'T-256', '2026-06-06 10:07:35', '2026-06-06 19:53:07', NULL, '2dbd4405f72911a87fd757c819b9f0c5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6WZ7DKVDFAGQ4QDMG', 'T-258', '2026-06-06 20:55:49', '2026-06-06 20:55:49', NULL, '4bcfdcf889c4bf6ca3405fa7b4857520', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7RQMN1GKEZ5JQKJT0', 'T-259', '2026-06-06 21:12:44', '2026-06-06 21:12:44', NULL, '9efbdb558a1358b67827a0814fa5c23e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5JK0RHMRAH47K8ND4', 'T-36', '2026-04-22 20:34:09', '2026-06-06 21:14:22', NULL, 'bab3e0865388263d61db6651c3e5a55d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6DSPH569T4458B8JC', 'T-260', '2026-06-06 21:46:42', '2026-06-06 21:46:42', NULL, 'e45747cbef04e469b21ef0b4f2a3122f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7GJW4V3CXMMXXV988', 'T-261', '2026-06-07 08:03:10', '2026-06-07 08:03:10', NULL, '54c9a91b6b4408f05c85ab0afb7daf47', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7GCVGCH9RMGYHENCC', 'T-54', '2026-04-23 20:32:06', '2026-06-07 08:03:17', NULL, 'ed036c636484f9b724fe82dace896d49', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QW8VH454J8V3E174', 'T-252', '2026-06-06 08:28:43', '2026-06-07 08:32:36', NULL, 'cb647a08e949e13f6386fefbb99c8c6e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM63WP564EZRERJHVCM', 'T-255', '2026-06-06 09:56:23', '2026-06-07 08:51:08', NULL, 'e2aa43fb1e6a1c9c28ebd1fe2ab6bd79', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM74JAV0MJMB4VQNTRG', 'T-268', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '796bd5eff55061d92fe00b3c4b15d3ad', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6P9YQ5V7WS6RFJNHW', 'T-269', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '6fc792db7c0d2ba8f8c73c1e1960faf6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4XBRZJ6DEHFWREWC0', 'T-271', '2026-06-07 10:42:32', '2026-06-07 10:42:32', NULL, 'a7a8244eab996f86026f60edd5969de6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67PDXAFTPPW7B1WDR', 'T-272', '2026-06-07 10:42:38', '2026-06-07 10:42:38', NULL, 'd3fcdcc97810a9b5f6b0624a3507c952', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM40JE9MZZWCXC91THG', 'T-270', '2026-06-07 10:23:13', '2026-06-07 10:43:18', NULL, '014c8442d8f19f6cc075ad8fc27ce9ed', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM551VZ2NYV6PD0XQRC', 'T-48', '2026-04-23 20:30:09', '2026-06-07 15:40:06', NULL, '8389d1a6a5d2db863bda0f7405d9496a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM71P14PAF7K4QE61G0', 'T-262', '2026-06-07 08:10:42', '2026-06-07 17:41:11', NULL, '561c4e90d842e6ac1a22dcacd7a5e33f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5W6VN98RQM6S22X28', 'T-274', '2026-06-08 07:26:13', '2026-06-08 07:46:46', NULL, 'd9177bbb472fd16a13d4f23e0cd70344', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'T-276', '2026-06-08 07:46:23', '2026-06-08 07:53:32', NULL, '1b77c5f3a8d66247532ead2164b6b37f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60PAS5RJ55CZGPM1G', 'T-277', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, '70e24eabce743397720db9d6029a3337', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7XXSERDHKR0EEH3N4', 'T-263', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, '1cb588e500a902d2b880f3004c5c33b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45HHRBCRMV2166Y6M', 'T-266', '2026-06-07 08:41:13', '2026-06-08 09:04:12', NULL, '9162b1edab8984a25660f0fdcebcd41d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YYMMKFD5Z8WG9F60', 'T-264', '2026-06-07 08:40:25', '2026-06-08 09:25:50', NULL, '4b9c66ff7114ce85456f57f6d1c58d7b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4Q1Q97CHA0XNK2G4W', 'T-265', '2026-06-07 08:40:33', '2026-06-08 10:09:14', NULL, 'ff24fcb74089501588a4bfcfa62c9aad', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7ZGZ9GQ5W1MKV00Y4', 'T-267', '2026-06-07 08:42:53', '2026-06-08 10:09:21', NULL, 'd860955c7a7030d4c1c2ea98e5447fdf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM55CV3VDYS88VBJZ60', 'T-273', '2026-06-07 17:39:10', '2026-06-08 11:44:27', NULL, '02cd1eaf484bd3adef09a71a7408674e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM59DG2WADJ9A0JFD1W', 'T-281', '2026-06-08 11:46:11', '2026-06-08 11:49:36', NULL, '1d9df18227de229f12bda189c4081353', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6K1QRC911JMQ9C960', 'T-283', '2026-06-08 13:39:51', '2026-06-08 13:44:57', NULL, '56929fab7dc064046853959720d43b8b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6A6A56VFSVRRBKMP0', 'T-279', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, '1023e2106149f0366bf3896635ec44c4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KFHAS1WZFGBJ6JN4', 'T-284', '2026-06-08 14:55:43', '2026-06-08 14:57:15', NULL, '75d5f69fd00e63d09e707ad1e8338853', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6GZ55VD4SAZZ6XXP4', 'T-285', '2026-06-08 15:22:32', '2026-06-08 15:37:29', NULL, '994bf6438e27af887c2f3d247e76d576', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7W2MWZ45YNK7TJGC4', 'T-282', '2026-06-08 13:39:15', '2026-06-08 15:42:01', NULL, '9d5f153703ebeae7f011f555f380ef6e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5AAA9WW54JGP83HGW', 'T-275', '2026-06-08 07:28:34', '2026-06-08 16:52:13', NULL, '886bd74d7ec01145f8acb5c7d9397307', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6NNRR61E8P6GHXABG', 'T-289', '2026-06-08 20:16:22', '2026-06-08 20:16:22', NULL, '6c2022166a2139a665912c3fe6d4802e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4501EBPDFDH84RJ5G', 'T-288', '2026-06-08 17:52:51', '2026-06-09 06:19:01', NULL, '16b1c85fab4273ebe7c9cfb9b9800e3e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5R93N5J6RNVTBRB28', 'T-286', '2026-06-08 16:30:27', '2026-06-09 06:19:36', NULL, '22c1b0f40e3e3344f0c624af618bdaff', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM44CKN5QQGFKP1KWZC', 'T-290', '2026-06-09 07:15:14', '2026-06-09 07:15:14', NULL, '53ca5591666fa7c01d90174bf28ecf4b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM45BSF1B0R0JA1Z7FC', 'T-291', '2026-06-09 07:15:22', '2026-06-09 07:15:22', NULL, '79e9f769c9313f0bc63d5c8c8a1ee641', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5E41Q8TX8X55X4MZ0', 'T-29', '2026-04-22 20:17:26', '2026-06-09 10:46:41', NULL, '93ab2ce409ff22e37a756e41553c0a69', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50BPNDT50PM24ZVA4', 'T-278', '2026-06-08 09:20:00', '2026-06-09 15:03:23', NULL, '0c340b4aeb41ddaa858ae18888207b2e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BD8PXDRHBF001200', 'T-292', '2026-06-09 14:57:32', '2026-06-09 15:09:05', NULL, 'b907503e714ddc775257c90330c25d2f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5DM43Q9HX352STN3M', 'T-295', '2026-06-09 15:26:00', '2026-06-09 15:26:00', NULL, 'c93802bd37eb8dfe981c74cfde3f24c4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42WW0SY3XEXKE8PK8', 'T-293', '2026-06-09 15:12:15', '2026-06-09 15:36:15', NULL, '55da76c0ebfc56f44cb4096a2a17b9c6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4GN07FN8GAXZ20YAR', 'T-236', '2026-06-03 15:31:01', '2026-06-09 15:55:06', NULL, '18d3d9bc2bc268d007d27c4cc20fcea8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM61SRVBDRFFN2S80T4', 'T-254', '2026-06-06 09:32:54', '2026-06-09 15:55:06', NULL, 'ae31cf118f94e93699eed5f0ac0eee7c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM63TRGBFRWK9W2WA98', 'T-296', '2026-06-09 15:43:58', '2026-06-09 16:34:46', NULL, '405de86b123fc1aeccf6f444a36bbf36', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM72SHY93S36VM9G6D8', 'T-294', '2026-06-09 15:18:22', '2026-06-09 16:34:46', NULL, 'baa483c978c2744aaf95035de3809048', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RXN6HW6XTEHKYQDG', 'T-297', '2026-06-09 16:01:09', '2026-06-09 16:34:46', NULL, 'f228757d5b5ed41b7cf90b6035e09dda', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7K1FN7KD8S5ZVX2VM', 'T-299', '2026-06-09 16:42:47', '2026-06-09 16:42:47', NULL, 'a2fdfc4298353c34568d42e71c2786e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM53HQD6YV754GKBA60', 'T-44', '2026-04-23 20:25:26', '2026-06-09 16:56:55', NULL, '75a46ae8c87aa5a9a27e513b4ea65e58', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'T-158', '2026-05-23 20:48:38', '2026-06-09 17:16:34', NULL, '1282d8b5cb009f639f7c7a14e69ef2b3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7D5DMYF5X92Q55CBW', 'T-235', '2026-06-03 15:17:29', '2026-06-09 17:16:34', NULL, '1bccb4cf47ba2a1a72e4f65a214e2f78', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7W0NPAEG3KWNFY9DR', 'T-132', '2026-05-22 15:45:15', '2026-06-09 17:23:25', NULL, 'cf1709ddff1d13a7b4adb0dc193b319b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'T-300', '2026-06-09 20:02:41', '2026-06-09 20:03:08', NULL, '6f1c204fc4f81403ba50e9e7ecff7241', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6048VEGFDNEBHFMM4', 'T-301', '2026-06-09 20:04:29', '2026-06-09 20:04:54', NULL, '89490f3e4d3e09714b70f0e35c726038', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4AE2GEF6BXSGKRMGC', 'T-242', '2026-06-05 10:17:47', '2026-06-09 20:07:30', NULL, 'c570cc4f2808f31192d947affb3392a0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42VPM50VKAWXBCAM8', 'T-253', '2026-06-06 09:32:53', '2026-06-09 20:17:04', NULL, '47b9d1a167b65553a14ede7a5a53f851', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7H8JWCP59WQVD0FW4', 'T-302', '2026-06-09 20:24:54', '2026-06-09 20:25:49', NULL, '8e04d93f6163a8f54965bc5988dae31c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5QJC039TMDF5JGMXC', 'T-240', '2026-06-05 09:38:46', '2026-06-09 20:27:17', NULL, '4879c042965effced12e608af5739659', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM531VE0C6N36AT12SR', 'T-241', '2026-06-05 10:02:31', '2026-06-09 20:34:30', NULL, 'b7de744d4e5292306b1fa64bcc3ba1aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4K1MTP2MHRKKNXBFM', 'T-303', '2026-06-09 20:41:22', '2026-06-09 20:41:22', NULL, '1adc6db2fcc64d6fd36ad0406939a8d7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4BRWWPB4R06Q31XV0', 'T-56', '2026-04-23 20:32:06', '2026-06-09 20:57:53', NULL, '5a394b49d98e119d21b637745798fd8b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6SK3D1QYACBPHHBZ0', 'T-287', '2026-06-08 17:12:49', '2026-06-09 21:21:08', NULL, '7d9537b53ade941ecd8a8efadd591c17', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'T-304', '2026-06-09 21:22:28', '2026-06-09 21:22:44', NULL, '252c572a5954883d0e5e71406b092a7f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50S6WRD81V3Z0NH1M', 'T-298', '2026-06-09 16:06:41', '2026-06-09 21:36:05', NULL, 'f8666523eaf2642b918f6846e2801539', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YNEQM1WY25BV7TD8', 'T-280', '2026-06-08 11:29:22', '2026-06-09 21:42:59', NULL, 'e9859dd425e0ac667ddbce721415572e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'T-305', '2026-06-10 08:15:39', '2026-06-10 08:15:39', NULL, '288b0c9d74e98c2ac42ee25d3369f689', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16YM1Y910T07Y1Y22MGTEM', 'T-306', '2026-06-10 08:17:42', '2026-06-10 08:17:42', NULL, 'af36af77224b697a61cb5447955605e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB170DJA02EH2E8HMH7X7SS4', 'T-307', '2026-06-10 08:17:57', '2026-06-10 08:17:57', NULL, '882cca1753c2ede7bfe2aef142545b76', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'T-308', '2026-06-10 09:28:04', '2026-06-10 09:28:04', NULL, '5050ff0e69b24a93f5b7e96c8a9fa47e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'T-309', '2026-06-10 09:37:31', '2026-06-10 09:37:31', NULL, 'b4f48310ea6b0f49f83c46e3bc5b7ad8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1SJKA7KRCQMZE41SPQG780', 'T-310', '2026-06-10 09:39:05', '2026-06-10 09:39:05', NULL, '9088122bf9d5e04ca8a7fee3bf031a53', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'T-311', '2026-06-10 09:55:55', '2026-06-10 09:55:55', NULL, 'ed03464125d09ff5f23913e6247064e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1ZFJK6J2GSV4SA69QF730C', 'T-312', '2026-06-10 10:04:53', '2026-06-10 10:04:53', NULL, 'dd22af12b333c13376b23aa5381ea2d7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'T-313', '2026-06-10 10:20:54', '2026-06-10 10:20:54', NULL, '9b7abb73ea56cfdad2039303a8935897', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'T-314', '2026-06-10 10:24:29', '2026-06-10 10:24:29', NULL, 'f8b89421a13f620c2a0e76bd780db5a2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'T-315', '2026-06-10 10:52:34', '2026-06-10 10:52:34', NULL, 'd0521ab80265d66c99c803a8714e8f02', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2AD3HPR3HXSVASVZEX8PK0', 'T-316', '2026-06-10 10:52:36', '2026-06-10 10:52:36', NULL, '5beb005b263eb4c5bb10ffc6e2fc0100', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EDCBYRBDSV9V1PJ1KE3CM', 'T-317', '2026-06-10 11:10:07', '2026-06-10 11:10:07', NULL, '4d4a02ab95987e3ebb134a2f0173b4a1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ERREMEEF26KKHGNZBWW64', 'T-318', '2026-06-10 11:11:40', '2026-06-10 11:11:40', NULL, '31820aadd1f19f9dd0cf931dae565abc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'T-319', '2026-06-10 11:11:45', '2026-06-10 11:11:45', NULL, '12c5a57fb83444410cca98a802e9ba6f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'T-320', '2026-06-10 11:11:55', '2026-06-10 11:11:55', NULL, '2266e362f144e4fd97403e1d1abe3eb4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'T-321', '2026-06-10 11:11:59', '2026-06-10 11:11:59', NULL, 'd48b6bd9f0c8289c95448dda6865d666', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'T-322', '2026-06-10 11:17:17', '2026-06-10 11:17:17', NULL, 'f33b1db4b67a0521f02c5c49fcfa8e6f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'T-323', '2026-06-10 11:17:23', '2026-06-10 11:17:23', NULL, '0738a8da475a6d8d49132d1e4e753775', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'T-324', '2026-06-10 11:26:07', '2026-06-10 11:26:07', NULL, 'd8aa9ae4522d97b70331c42b8ab7a365', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- Auto-generated by migrate-ids (D-26). CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15.
|
||||||
|
-- pql:created_by: migrate-ids
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- Auto-generated by migrate-ids (D-26). CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15.
|
||||||
|
-- pql:created_by: migrate-ids
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -16,6 +16,893 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.2.0] — 2026-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`clide://` deep links open files, safely.**
|
||||||
|
`clide://open?path=/repo/file.dart&line=42` opens the file at that line (CI
|
||||||
|
links, error reports), routed through the CLI→IPC path into the running
|
||||||
|
window. As an untrusted external vector it's gated by a default-deny allowlist
|
||||||
|
(navigation only) and a confirmation prompt before any action. Registered on
|
||||||
|
Linux + macOS. (T-56, D-90)
|
||||||
|
- **Number keys pick prompt buttons (CLI muscle memory).** In a permission or
|
||||||
|
AskUserQuestion prompt, `1`/`2`/`3`… select the matching button or option
|
||||||
|
(labels are now numbered), and Enter confirms the primary action. Typing in a
|
||||||
|
note field is unaffected — digits only act while the card itself holds focus.
|
||||||
|
(T-240)
|
||||||
|
- **Links in the Claude conversation are clickable.** An http(s) link (typed or
|
||||||
|
autolinked) now opens in your default browser on click — with a hover
|
||||||
|
underline + pointer — across prose, lists, tables, and headings. Non-http
|
||||||
|
schemes stay inert. (T-253)
|
||||||
|
- **The activity-card fold level is now adjustable and persists.** A
|
||||||
|
`claude.activity.fold-level` command cycles how aggressively meta steps fold
|
||||||
|
(none → tools → thinking → everything); the choice is saved app-wide and the
|
||||||
|
Claude pane and team tiles re-fold live. (T-235)
|
||||||
|
- **Consecutive edits to one file fold into a single card.** A run of same-file
|
||||||
|
edits collapses to one `# edits` holder instead of a stack — every edit
|
||||||
|
reachable on expand; a different file or an interleaving step splits it. The
|
||||||
|
card shows an aggregate live status: a logo-mark spinner while editing,
|
||||||
|
settling to a check or cross. (T-296)
|
||||||
|
- **Collapse toggles in the status bar.** A small caret-line button bookends
|
||||||
|
each end of the bottom status bar — left collapses/expands the sidebar, right
|
||||||
|
the context pane. The chevron points inward to collapse, outward to expand,
|
||||||
|
and fires the existing `sidebar.collapse` / `context.collapse` commands
|
||||||
|
(`Ctrl+Shift+1` / `Ctrl+Shift+3`), so it's the mouse affordance for an
|
||||||
|
already keyboard/CLI-addressable action. (T-294)
|
||||||
|
- **Pasted images render inline in the Claude conversation.** A pasted-image
|
||||||
|
`@<path>` reference shows as a bounded thumbnail instead of the raw path;
|
||||||
|
clicking it (or Enter when focused) opens it in the lightbox. The composer's
|
||||||
|
attachment previews use the same larger thumbnail. A missing file degrades to
|
||||||
|
a placeholder; the sent text is unchanged. (T-236, T-254, D-89)
|
||||||
|
- **The editor honours `.editorconfig`.** Opening a file resolves the
|
||||||
|
workspace rules into a source-agnostic `EditorSettings` (own INI parser +
|
||||||
|
glob matcher, `root`/nearest-wins precedence — no new dependency). The editor
|
||||||
|
indents with Tab/Shift+Tab and draws a `max_line_length` ruler; saving applies
|
||||||
|
`end_of_line`, `trim_trailing_whitespace`, and `insert_final_newline`. Saving
|
||||||
|
the `.editorconfig` re-resolves open buffers live. (T-29)
|
||||||
|
- **Permission-mode control beside the Claude composer.** An icon-only,
|
||||||
|
per-mode-coloured button opens a menu of the safe modes (default ·
|
||||||
|
accept-edits · plan); `bypass` shows disabled. The status-bar mode is now a
|
||||||
|
passive colour-coded indicator — switching lives in the control and
|
||||||
|
`Ctrl/Cmd+M`. (T-275)
|
||||||
|
- **Clickable T/D/Q/R cross-refs in the Claude conversation.** Bare ticket and
|
||||||
|
governance references (`T-281`, `D-77`, `Q-5`, `R-2`) in rendered messages are
|
||||||
|
now links that open the record in its context-pane reader — tickets for `T-`,
|
||||||
|
decisions for `D`/`Q`/`R`. Refs inside code stay literal. (T-279)
|
||||||
|
- A Zed-style **application menu bar** in the hat — **File / View / Help**
|
||||||
|
menus built from custom widgets (no native menu, D-7), populated from the
|
||||||
|
command registry with inline keybindings. Full keyboard nav (`Alt`+mnemonic,
|
||||||
|
arrows, Enter, Esc). Help → About shows version + bundled licenses. `Ctrl+O`
|
||||||
|
and `Ctrl+Shift+N` are now real keybindings. (T-48)
|
||||||
|
- The sidebar/dock **filter boxes are now CLI-addressable** (D-6 parity): `clide
|
||||||
|
ui filter <address> <text>` drives a pane's filter as typing would, and `clide
|
||||||
|
ui filter <address>` reads it back. Addresses are box ids from `clide pane
|
||||||
|
list` (e.g. `decisions.panel`, `files.tree`). Routed through the MessageBus, so
|
||||||
|
a click and the CLI behave identically. (T-270)
|
||||||
|
- Click an inline image card to open it in a full-screen **lightbox** — zoom
|
||||||
|
(scroll/pinch), pan, double-click to reset, `Esc`/backdrop to dismiss — since
|
||||||
|
the cards are often too small to read. `clide image show <path> --fullscreen`
|
||||||
|
opens straight into it. The lightbox is a reusable `ClideLightbox` primitive.
|
||||||
|
(T-252)
|
||||||
|
- A bottom **output dock**: toggle it from a status-bar widget (or `⌘J`/`Ctrl+J`)
|
||||||
|
to see logs (Output) and diagnostics (Problems) as tabs — filterable by
|
||||||
|
source/level/text, auto-scrolling. The status widget doubles as a health
|
||||||
|
badge (green `✓` clean, `⚠`/`✕` counts otherwise) and replaces the old
|
||||||
|
app-status item; Problems moved here from the sidebar. (T-54, D-87)
|
||||||
|
- External MCP clients (Cursor, Windsurf, Copilot, …) can now drive clide: the
|
||||||
|
MCP server exposes the full `mcp__clide__*` tool surface, generated from the
|
||||||
|
command registry that already feeds the CLI + palette (D-86), with a
|
||||||
|
per-command opt-out. The two `/ide` tools remain stubs. (T-225)
|
||||||
|
- `clide events --since <cursor> [--filter X]` reads events after a cursor and
|
||||||
|
returns them plus a next-cursor — the pull-based complement to the
|
||||||
|
`tail --events` stream, made for agent poll loops. Reports `gap: true` when
|
||||||
|
the cursor has aged out of the in-memory ring (D-85). (T-223)
|
||||||
|
- "Install 'clide' command in PATH" command (`clide.installCli`) copies the
|
||||||
|
bundled C client to `~/.local/bin`, VS Code style. On launch clide warns when
|
||||||
|
`clide` is missing from PATH or points at the GUI bundle instead of the CLI
|
||||||
|
client. (T-212)
|
||||||
|
- `clide ui open diff <path>` reveals the diff in a split above the Claude
|
||||||
|
conversation, scrolls to that file and highlights its header — the
|
||||||
|
diff-panel arm of `ui open`. Workspace tabs other than Claude/editor now
|
||||||
|
reveal alongside the conversation with a close affordance (T-233).
|
||||||
|
- Image cards in the Claude conversation log: `clide image show <path>
|
||||||
|
[--caption …]` renders an image inline (PNG/JPG/JPEG/GIF/WebP/BMP),
|
||||||
|
clide-owned and display-only (D-78). The path is resolved workspace-relative
|
||||||
|
and must exist; the verb registers in the dispatcher so it shows up in `clide
|
||||||
|
capabilities`. (T-249)
|
||||||
|
- `clide capabilities` lists the live command surface as JSON (every verb with
|
||||||
|
its subsystem + argument schema), reflected from the dispatcher so it never
|
||||||
|
drifts. A new `/clide` skill points Claude at it for discovery. (T-248)
|
||||||
|
- Toast notifications for operation feedback: non-modal cards slide in
|
||||||
|
bottom-right, auto-dismiss (errors linger), stack, and are manually
|
||||||
|
dismissable, with success/warning/error/info severities. Components raise
|
||||||
|
them by publishing to the kernel MessageBus — git push/pull show the first
|
||||||
|
ones. (T-50)
|
||||||
|
- `clide ui toast "message" [--severity …] [--duration MS]` raises a toast in
|
||||||
|
the live GUI from the CLI — so an agent or script can surface "done/failed"
|
||||||
|
on your screen. The drive-half complement to the toast system. (T-245)
|
||||||
|
- Claude pane folds runs of tool calls/results into a collapsible "activity
|
||||||
|
card" so prose isn't buried: collapsed by default with a live one-line ticker
|
||||||
|
+ step count, click/Enter to expand. Claude prose, user messages, and failed
|
||||||
|
results stay first-class; diffs and thinking stay visible at the default
|
||||||
|
level. (T-230)
|
||||||
|
- Theme switcher in the status bar: a far-right control showing the current
|
||||||
|
theme that opens a popover to switch live — click or keyboard (arrows/Enter,
|
||||||
|
Esc to dismiss). The `theme.pick` palette command is unchanged. (T-234)
|
||||||
|
- Catppuccin Mocha theme, plus a high-contrast `catppuccin-mocha-hc` sibling,
|
||||||
|
added to the bundled themes — switchable from the theme picker. Faithful to
|
||||||
|
the official palette (D-69). (T-82)
|
||||||
|
- `clide ui open <reader> <id|path>` opens a doc in a GUI reader from the CLI —
|
||||||
|
`tickets`/`decisions` by id, `markdown` by path — so an agent can surface what
|
||||||
|
it's looking at on your screen. The drive-half complement to `clide status`. (T-231)
|
||||||
|
- Cycle Claude's permission mode from the primary pane: Ctrl/Cmd+M while the
|
||||||
|
composer is focused, a clickable mode badge in the status line, or the
|
||||||
|
"Claude: Cycle permission mode" palette command — steps default → accept-edits
|
||||||
|
→ plan (bypass stays behind the cockpit's confirm). (T-226)
|
||||||
|
- `clide status` — a one-shot orientation snapshot for agents: workspace, git
|
||||||
|
summary, active editor buffer + selection, viewed reader docs, the live panes
|
||||||
|
the user sees, and the layout. Previously an unknown command (exit 3). (T-221)
|
||||||
|
- `clide pane list` now reflects the live GUI tabs the user sees (Claude, Files,
|
||||||
|
Editor, viewers) alongside PTY panes — each with a stable id, slot, title, and
|
||||||
|
active/visible state — by snapshotting the kernel panel layout at request time.
|
||||||
|
Restores the D-6 "agent sees what the user sees" half of parity. (T-219, D-83)
|
||||||
|
- Click empty Claude-pane area to focus the composer: a tap on conversation
|
||||||
|
dead space lands the cursor in the input. Message controls and transcript
|
||||||
|
text-selection are unaffected, and it stays inert while a prompt occupies the
|
||||||
|
interaction zone. (T-227)
|
||||||
|
- Claude composer prompt history (Claude-CLI-style): Up recalls previously-sent
|
||||||
|
prompts once the caret reaches the first line, Down steps back to newer ones
|
||||||
|
and restores your in-progress draft past the newest. Per session. (T-163)
|
||||||
|
- clide-hosted Claude sessions are now bootstrapped to drive the IDE: each
|
||||||
|
spawned session gets `CLIDE_SOCK`/`CLIDE_WORKSPACE` in its env and `clide` on
|
||||||
|
its PATH, a system-prompt note telling it it is inside clide and how to use
|
||||||
|
`clide …`, and a `Bash(clide:*)` allow rule so those calls aren't prompted.
|
||||||
|
Applies to primary, secondary, fork, and teammate sessions. (T-214, D-83)
|
||||||
|
- The `clide` CLI now ships on PATH: `make build` and `make install` compile
|
||||||
|
the C client by default, and `make install` places it at `~/.local/bin/clide`
|
||||||
|
on Linux and macOS (the GUI launches via its desktop entry). Previously no
|
||||||
|
build produced the client and `install` symlinked the GUI runner. (T-209)
|
||||||
|
- Vim keymap preset: a modal editor (normal/insert/visual) with hjkl/w/b/e
|
||||||
|
motions, dd/dw/x/D/yy/p/cc/cw edits, counts (`5j`), visual-range d/y/c, and
|
||||||
|
a status-bar mode indicator. Switch presets from the palette (`Keymap: Vim`
|
||||||
|
/ `Keymap: Default`). Built on a new keymap key-sequence layer (D-82). (T-65)
|
||||||
|
- Search-and-replace across the workspace: enter a replacement in the search
|
||||||
|
panel to preview each rewritten line, then Replace all (regex capture groups
|
||||||
|
supported). Guarded by a clean-git-tree gate — git is the undo — and a
|
||||||
|
confirmation. New `search.replace` command (preview + apply). (T-53)
|
||||||
|
- Find-in-files sidebar panel (Ctrl/Cmd+Shift+F): search the workspace with
|
||||||
|
regex and case toggles plus include/exclude globs; results stream in grouped
|
||||||
|
by file and clicking a match opens the editor at that line. (T-52)
|
||||||
|
- Workspace content-search engine with `search.grep` / `search.cancel` commands:
|
||||||
|
a pure-Dart, isolate-parallel grep (literal or regex, case + include/exclude
|
||||||
|
glob filters) that streams matches and honours the `ignore_files:` chain. The
|
||||||
|
engine is in-process — no ripgrep dependency (D-79). (T-52)
|
||||||
|
- `editor.open` accepts an optional 1-based `line` to position the initial
|
||||||
|
selection on open (backs find-in-files click-to-line). (T-52)
|
||||||
|
- Quick-open file finder (Ctrl/Cmd+P): a fuzzy file picker overlay over the
|
||||||
|
whole workspace, separate from the command palette. Empty query lists recent
|
||||||
|
files; Enter opens `.md` in the markdown reader and other files in the editor.
|
||||||
|
(T-51)
|
||||||
|
- Workspace ignore now follows the `ignore_files:` list in `.pql/config.yaml`
|
||||||
|
(ordered, later-wins, per D-4) instead of a hardcoded `.gitignore` +
|
||||||
|
`.clideignore` pair — the single ignore knob clide owns. (T-52)
|
||||||
|
- `files.walk` command — a recursive, ignore-pruned, capped flat file listing
|
||||||
|
of the workspace, backing quick-open and search. (T-51, T-52)
|
||||||
|
- Sidebar readers (markdown, decision, ticket) gain chrome: a pin/unpin toggle
|
||||||
|
before the title (separate from navigation), plus a right-hand navigator
|
||||||
|
(back, forward, jump-to-pin) and edit pencil. The ticket reader also joins the
|
||||||
|
shared retained nav. (T-189, T-190, T-191, T-198, T-199)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Every tool use is now a collapsible card** on one `ClideCollapserCard`
|
||||||
|
primitive — activity / edit / sub-agent runs and each tool call (single = a
|
||||||
|
one-item list). The collapsed ticker shows the label + echoed last line + a
|
||||||
|
fixed-width count; the status tick hugs the right edge, the chevron the left;
|
||||||
|
`color` drives the border + label. (T-305)
|
||||||
|
- The Claude composer's **slash typeahead**, the team-chat **@-mention** list,
|
||||||
|
and the status-bar **theme switcher** now ride the shared
|
||||||
|
`ClideAnchoredOverlay` + `ClideMenu` popover primitive, alongside the menu
|
||||||
|
bar. The @-mention list gains full keyboard nav (arrows/Enter), and both
|
||||||
|
typeaheads narrow live as you type. (T-286, D-88)
|
||||||
|
- Ticket cards now show parentage as a small **tree** — the parent as a muted,
|
||||||
|
clickable breadcrumb above and the card's own ticket **bold** under a `└`
|
||||||
|
connector — instead of the ambiguous inline `T-1 ← T-9` arrow. (T-281)
|
||||||
|
- The in-flight **turn indicator** ("Pondering…") now renders in Claude's
|
||||||
|
coral-orange brand accent instead of muted grey. (T-273)
|
||||||
|
- A **sub-agent's prose and thinking** are now attributed to the **`agent`** (a
|
||||||
|
muted stripe), not the main-thread coral **`claude`** — so a sub-agent's
|
||||||
|
output is no longer presented as if the main Claude said it. Main-thread items
|
||||||
|
are unchanged. (T-265)
|
||||||
|
- A **sub-agent's whole run** — prose, thinking, and tool calls — now nests in
|
||||||
|
an **`agent run` holder under its Agent card** instead of spilling loose into
|
||||||
|
the main thread. It attaches via `parentUuid` (correct for parallel agents),
|
||||||
|
and the redundant returned-result is no longer shown twice. (T-264)
|
||||||
|
- The folded **activity card** now reads as one **container wrapping its
|
||||||
|
sub-cards**, and you can collapse it by clicking anywhere on the holder's own
|
||||||
|
background — not a top header that scrolls out of reach as a run streams. Taps
|
||||||
|
on a sub-card (and its copy button) still hit that card; a focusable caret
|
||||||
|
keeps the control keyboard/AT reachable. (T-266)
|
||||||
|
- A **sub-agent prompt** is no longer mislabelled as your input: a sidechain
|
||||||
|
prompt now reads as a muted **`agent prompt`** (never the blue `you`) and folds
|
||||||
|
into its **Agent/Task card**, collapsed by default. The prompt attaches to the
|
||||||
|
right card via `parentUuid`, so parallel agents in one turn stay correctly
|
||||||
|
paired. The sub-agent's work stays visible after the call. (T-263)
|
||||||
|
- A successful tool call now renders as **one merged card** instead of a
|
||||||
|
separate call + result pair: a green check sits at the header's right edge and
|
||||||
|
the output folds in as a colorized code block (Read → file grammar, Bash →
|
||||||
|
shell) revealed on expand. Failures keep their prominent red card, now with a
|
||||||
|
matching header mark. (T-262)
|
||||||
|
- The in-flight turn indicator now feels alive: instead of a static gray
|
||||||
|
`running…`, it shows a rotating curated status verb (`Pondering…`,
|
||||||
|
`Conjuring…`, …) with an animated ellipsis. Respects reduced-motion (static
|
||||||
|
verb) and keeps a stable a11y label. (T-255)
|
||||||
|
- The `clide` CLI launch check now distinguishes a dev-tree build
|
||||||
|
(`native/<plat>/clide`) from a packaged install — surfaced as an info note on
|
||||||
|
a checkout rather than treated as a clean install or prompting a reinstall.
|
||||||
|
(T-256)
|
||||||
|
- Command palette (⌘⇧P) now fuzzy-matches command titles (subsequence, not
|
||||||
|
just substring) and floats recently-used commands to the top. (T-23)
|
||||||
|
- ⌘K now opens a **Settings** modal instead of a theme-only picker. Its first
|
||||||
|
(currently only) section is Appearance — base themes, sorted, with a High
|
||||||
|
contrast toggle for `-hc` siblings — matching the status-bar switcher. (T-238)
|
||||||
|
- The pql search panel merged into the Search tab, which now has modes: Find
|
||||||
|
(content grep), Vault (pql ranked search), Query (PQL DSL), and Markdown (the
|
||||||
|
synced file listing). The standalone pql sidebar tab is gone; Backlinks stays
|
||||||
|
in the context panel. (T-201)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tab strips get a hairline of breathing room.** The tab strip (Claude session
|
||||||
|
tabs, slot tabs) butted flush against the chrome above it, reading as cramped;
|
||||||
|
it now sits 1px below, the pane surface showing through the gap. (T-324)
|
||||||
|
- **Re-showing an image after it changes on disk now refreshes.** Image cards,
|
||||||
|
thumbnails, the lightbox, and `clide image show` keyed Flutter's image cache by
|
||||||
|
path alone, so overwriting a file in place showed the stale render. A new
|
||||||
|
`ClideFileImage` folds mtime + size into the key, so an in-place change
|
||||||
|
re-decodes. (T-312)
|
||||||
|
- **The `context` / `thinking` / agent-prompt blocks are now carded like the
|
||||||
|
rest.** These muted meta blocks rendered frameless, reading as unfinished
|
||||||
|
`> context …` rows between the framed tool cards. They now sit in a bordered
|
||||||
|
card — still muted, collapsed by default, with a first-line summary and a left
|
||||||
|
chevron. (T-306)
|
||||||
|
- **Numpad digits now pick permission/question options too.** The prompt card's
|
||||||
|
number-key shortcuts only matched the top number row; numpad `1`-`9` now map to
|
||||||
|
the same 1-9 selection, so the keypad works for Allow/Deny and question options.
|
||||||
|
The note-field guard still lets digits type normally when a note is focused.
|
||||||
|
(T-310)
|
||||||
|
- **The composer no longer jams against the window bottom when the status bar
|
||||||
|
is hidden.** With the bar gone, the bottom-most pane content used to run flush
|
||||||
|
into the window's resize-drag edge; the layout now reserves that edge so the
|
||||||
|
input box bottom-anchors consistently whether or not the status bar shows.
|
||||||
|
(T-298)
|
||||||
|
- **Open Workspace no longer spews `GLib-GIO-CRITICAL` to the console.** The
|
||||||
|
folder picker now uses the portal-backed `GtkFileChooserNative` (out-of-process
|
||||||
|
in sandboxed/Flatpak builds), and a narrowly-scoped GLib log filter swallows the
|
||||||
|
known-benign `g_file_info_get_size … without standard::size` message GTK's
|
||||||
|
file-chooser sidebar emits internally on every pick — every other GLib-GIO
|
||||||
|
critical still surfaces. (T-287)
|
||||||
|
- **The conversation re-anchors when the input area resizes.** Opening a
|
||||||
|
permission prompt or AskUserQuestion (which grows the bottom zone, D-78) no
|
||||||
|
longer hides the last message behind it — when pinned to the tail, the view
|
||||||
|
re-scrolls to keep it visible; a scrolled-up reader is left undisturbed.
|
||||||
|
(T-297)
|
||||||
|
- **The chosen theme now persists across restarts**, per repo. Picking a theme
|
||||||
|
(status-bar switcher or Settings) writes it to the repo's
|
||||||
|
`.clide/settings.yaml` (and a global default), and reopening the repo restores
|
||||||
|
it — including the high-contrast variant. A removed theme falls back to the
|
||||||
|
default instead of resetting silently. (T-293)
|
||||||
|
- Folded activity and agent-run cards in the Claude conversation now use the
|
||||||
|
same bottom spacing as the prose cards around them, instead of sitting
|
||||||
|
cramped 3px below the next card. (T-282)
|
||||||
|
- The welcome screen no longer overflows on a short or narrow window — its
|
||||||
|
content scrolls when it can't fit and stays centred when it can, and a long
|
||||||
|
git-branch name on a recent-project row now truncates with an ellipsis. (T-273)
|
||||||
|
- Bordered conversation cards (tool / Agent calls) now use the same interior
|
||||||
|
vertical padding (8) as the stripe cards, so a collapsed tool/Agent card no
|
||||||
|
longer reads chunkier — taller box, more trailing space — than its
|
||||||
|
neighbours in the conversation log. (T-282)
|
||||||
|
- Conversation cards no longer **mis-associate their state** when the message
|
||||||
|
list reshapes as a tool result streams in. The list items now carry stable
|
||||||
|
per-item keys, so a card you expanded (or its hover/cluster state) stays
|
||||||
|
pinned to its own message instead of jumping to a neighbour when a read/write
|
||||||
|
completes and folds its result in. (T-285)
|
||||||
|
- The status-bar footer marquee now **honours reduced motion**: when the OS
|
||||||
|
reduce-motion setting (`MediaQuery.disableAnimations`) is on, a long status
|
||||||
|
line no longer scrolls — it renders statically (clipped) — matching the turn
|
||||||
|
indicator, which already obeyed the flag. Unifies the two animations on one
|
||||||
|
mechanism and removes a `pumpAndSettle` hang the perpetual ticker caused.
|
||||||
|
(T-284)
|
||||||
|
- Switching the workspace in place (Open Project/Folder) now rebinds the Claude
|
||||||
|
pane to the new repo's session instead of keeping the previous repo's
|
||||||
|
conversation, and drops the old repo's secondary tabs. Separate windows were
|
||||||
|
already isolated — this only affected reusing one window for another repo.
|
||||||
|
(T-269)
|
||||||
|
- `/clear` in the primary Claude pane now clears that session **in place** —
|
||||||
|
it empties the pane's deterministic, restart-stable session instead of
|
||||||
|
starting a throwaway random one. Previously a cleared primary was orphaned:
|
||||||
|
the next launch re-resolved to the deterministic id and resumed the
|
||||||
|
pre-clear conversation, so the clear silently didn't stick. Secondary panes
|
||||||
|
keep their fresh-session behaviour. (T-268)
|
||||||
|
- In Vim mode, Esc in insert/visual mode returns to normal mode instead of
|
||||||
|
closing the editor. The global "exit focus / close editor" Esc binding now
|
||||||
|
stands down while Vim is in insert or visual mode. (T-257)
|
||||||
|
- The permission-mode badge and Ctrl/Cmd+M now visibly cycle the mode in the
|
||||||
|
status line. The mode was changed on the session but never reflected back, so
|
||||||
|
both looked dead. (T-250)
|
||||||
|
- Recent-project rows (welcome screen and the project switcher) now ellipsize a
|
||||||
|
long path instead of overflowing the row — a long repo path no longer spills
|
||||||
|
past the edge. (T-122)
|
||||||
|
- CLI commands that take arguments now work: `clide editor open <path>`,
|
||||||
|
`clide files read <path>`, `clide pane focus <id>` / `resize <id> <c> <r>`,
|
||||||
|
etc. now bind positional/flag argv to the handler's named args (they
|
||||||
|
previously returned "X is required" from the CLI). (T-232)
|
||||||
|
- The Claude composer no longer loses a half-typed message when the UI changes
|
||||||
|
under it — e.g. a permission prompt taking the composer's place. The draft
|
||||||
|
(text and caret) is kept per session and restored when the composer returns.
|
||||||
|
(T-228)
|
||||||
|
- File watcher no longer emits change events for files inside ignored
|
||||||
|
directories (`.dart_tool/`, `build/`, etc.): it now checks ancestor dirs,
|
||||||
|
not just the leaf. Most visible on macOS, where FSEvents delivers the nested
|
||||||
|
creates that inotify usually drops.
|
||||||
|
- The Claude sidebar's Activity / Team / Config sub-tabs are now keyboard-
|
||||||
|
activatable: they were pointer-only (raw `GestureDetector`), so Tab traversal
|
||||||
|
skipped them and Enter/Space did nothing. They now use `ClideTappable`
|
||||||
|
(focusable, Enter/Space → activate) and carry button + selected semantics.
|
||||||
|
(T-182)
|
||||||
|
- The default keymap is no longer silently disabled at startup: `default.yaml`
|
||||||
|
bound Tab/Shift+Tab to undefined `focus.next`/`focus.previous` intents, which
|
||||||
|
made the loader drop the entire preset (palette, quick-open, find-in-files,
|
||||||
|
zoom). Those are now real focus-traversal intents, and a test parses every
|
||||||
|
shipped preset so a typo fails CI instead. (T-204)
|
||||||
|
- Opening the editor split no longer floods exceptions: the resize handle's
|
||||||
|
slider semantics now carry increased/decreased values, and the Claude pane
|
||||||
|
keeps a stable identity across the reparent so its text-selection region
|
||||||
|
isn't torn down mid-update. (T-203)
|
||||||
|
- A `rate_limit_event` whose `resetsAt` is a numeric epoch no longer crashes the
|
||||||
|
Claude session — it was cast as a string. (T-202)
|
||||||
|
- Filter/search inputs show their hint as visible placeholder text, and the
|
||||||
|
search-glass icon is now optional — so the Search tab's Find fields (search,
|
||||||
|
replace, include/exclude globs) are distinguishable instead of four identical
|
||||||
|
empty boxes. (T-201)
|
||||||
|
- The sidebar icon rail no longer overflows when there are more tabs than fit:
|
||||||
|
it centers the icons when they fit and scrolls horizontally otherwise. (T-200)
|
||||||
|
- The editor pane now opens over the Claude pane when a file is opened — the
|
||||||
|
reader's edit pencil, a file-tree click, or a decision's edit all reveal the
|
||||||
|
editor tab now (it was contributed but never activated). (T-197)
|
||||||
|
- Clicking a decision opens it on the first click. The right-pane readers
|
||||||
|
(markdown + decisions) now share a retained back/forward nav history that
|
||||||
|
survives the tab switch, so the selection that reveals a reader is no longer
|
||||||
|
lost before the widget subscribes; back/forward re-emit through that history.
|
||||||
|
(T-196)
|
||||||
|
- The markdown reader can now open user-scope Claude config files (skills /
|
||||||
|
agents / commands under `~/.claude`), not just repo-local ones. `files.read`
|
||||||
|
gained a read allow-list covering the workspace plus the trusted Claude config
|
||||||
|
roots; writes stay repo-confined and off-root paths are still rejected. (T-195,
|
||||||
|
D-80)
|
||||||
|
- The markdown reader opens files given an absolute path again (e.g. a skill's
|
||||||
|
`SKILL.md` from the Claude Config tab). `resolveUnderRoot` no longer doubles an
|
||||||
|
absolute path onto the workspace root; absolute-under-root resolves, while
|
||||||
|
paths outside the root are still rejected. (T-194)
|
||||||
|
- The composer slash typeahead now lists clide-owned commands — `/resume` and
|
||||||
|
`/fork` (and `/clear`) surface even though the CLI probe doesn't advertise
|
||||||
|
them, unioned onto whatever command source the composer uses. (T-162)
|
||||||
|
- Clicking a decision opens it in the decision reader again — the decisions
|
||||||
|
extension no longer tears down and re-contributes its panel tab on every
|
||||||
|
selection; it activates a static tab and reveals the panel like the ticket
|
||||||
|
panel. (T-188)
|
||||||
|
- Clicking a markdown file opens it in the right-side markdown reader again —
|
||||||
|
the files panel, the Claude Config tab, and wiki `.md` links now publish to
|
||||||
|
the reader instead of the editor. (T-187)
|
||||||
|
- A forked Claude session now reports its real session id (captured from the
|
||||||
|
branch's `init` event) instead of the placeholder it was spawned with, so a
|
||||||
|
fork can itself be resumed/forked. (T-185)
|
||||||
|
- Claude replies now stream token-by-token. The `--include-partial-messages`
|
||||||
|
output arrives as `stream_event` deltas (not `assistant`+`partial:true` as
|
||||||
|
first assumed), so the previous handler never fired; the session now reads the
|
||||||
|
real shape, growing a placeholder in place and finalizing it from the matching
|
||||||
|
`assistant` event. (T-184)
|
||||||
|
- Claude conversation card actions (copy + custom) are now keyboard-focusable
|
||||||
|
and always reachable — revealed on hover or focus, activatable by Tab +
|
||||||
|
Enter/Space, with Semantics labels for assistive tech (T-174).
|
||||||
|
- Status bar no longer overflows when the focused-pane context line is long
|
||||||
|
— the in-pane slot now takes a flexible share of the bar and marquee-scrolls
|
||||||
|
within it instead of pushing the row past its width.
|
||||||
|
- Write/Edit permission cards no longer print the file path twice — the
|
||||||
|
description line is suppressed when it just repeats `file_path`.
|
||||||
|
- Resumed Claude session no longer starts with an empty pane — `claude
|
||||||
|
--resume` carries Claude's prior context but emits no past turns over
|
||||||
|
stream-json, so the orchestrator now seeds the conversation by reading
|
||||||
|
the tail (up to 256 KB) of the transcript JSONL on disk.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Claude conversation tool cards are now typed — Edit/Write render a diff,
|
||||||
|
Bash shows the command and its output, Read/Grep show the file/query, and
|
||||||
|
each tool result pairs back to its call to render the diff or error in
|
||||||
|
place instead of an indented JSON dump (T-168).
|
||||||
|
- Claude status line reflects live session state from stream-json events —
|
||||||
|
model, permission mode, context size, and turn cost come straight off the
|
||||||
|
init/result events rather than a separate config probe (T-168).
|
||||||
|
- Claude session persistence now rides on `claude --resume` instead of tmux
|
||||||
|
(T-167, amends D-41). A restart resumes the primary session, `/clear`
|
||||||
|
starts a fresh one, and `/resume` reopens a picked session — all without
|
||||||
|
tmux.
|
||||||
|
- Permission prompt cards render the tool input in the shape that fits the
|
||||||
|
tool — Bash shows the command as a shell code block (with a footer for
|
||||||
|
`run_in_background` / `timeout`), Write shows the path plus the content
|
||||||
|
highlighted from its extension, Edit shows the path plus before/after
|
||||||
|
blocks. Unknown tools fall back to the indented-JSON dump.
|
||||||
|
- Claude meta sidebar is now tabbed — Activity / Team / Config (T-182).
|
||||||
|
Activity shows usage stats plus the primary session's live runtime; Team
|
||||||
|
holds the roster and auto-fronts when a team spawns; Config shows the
|
||||||
|
environment settings table. Activity and Config share one table geometry so
|
||||||
|
switching doesn't jump.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- tmux is no longer used for Claude sessions (T-167) — the tmux session
|
||||||
|
lifecycle and the tmux-polling team observer are gone, replaced by the
|
||||||
|
managed-session orchestrator. tmux is still used for the general-purpose
|
||||||
|
terminal pane.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Claude team cockpit — the meta sidebar's Team tab gains live controls for
|
||||||
|
clide-managed agents: show/hide, mute, close, and inject-a-message per
|
||||||
|
roster row, plus a live shared task list with reassign. Each action has a
|
||||||
|
matching `clide` command (D-6 parity). (T-171, D-77)
|
||||||
|
- Per-agent permission-mode badge in the cockpit roster — click cycles the
|
||||||
|
safe trio default → acceptEdits → plan and sends `set_permission_mode` to
|
||||||
|
that session; Shift-click reaches `bypassPermissions` behind a confirm.
|
||||||
|
The badge reflects the live mode. (T-181, D-77)
|
||||||
|
- Fork a Claude conversation into a new pane — `/fork` (or a roster Fork
|
||||||
|
button / `clide.agent.fork`) branches a session via `--resume … --fork-session`,
|
||||||
|
opening an independent continuation that leaves the original untouched. (T-172, D-77)
|
||||||
|
- Team chat inbox — broker traffic renders as a chat timeline (colour-coded
|
||||||
|
sender chips), in a compact cockpit widget that pops out to a full pane. The
|
||||||
|
user is a first-class participant: post with `@name` routing (or broadcast),
|
||||||
|
with an interrupt tickbox that cancels the target's turn before delivery. (T-180, D-77)
|
||||||
|
- Config sidebar tab — a pinned settings table plus expandable, never-truncated
|
||||||
|
sections for skills / agents / commands / hooks / permissions (colour-coded by
|
||||||
|
kind) / MCP servers; file-backed entries open their `.md` in the reader. ClaudeConfig
|
||||||
|
now also surfaces agents, hooks, MCP servers, and file paths. (T-183, D-76)
|
||||||
|
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
|
||||||
|
server (`clide-team`) for managed sessions over the stream-json control
|
||||||
|
channel, giving agents tools to message each other, broadcast, see the
|
||||||
|
roster, read an inbox, and share a task list. Each agent's role and the
|
||||||
|
roster are injected into its system prompt.
|
||||||
|
- Interrupt a running Claude turn (D-78) — Escape in the composer (when no
|
||||||
|
typeahead is open) or a Stop button shown while busy cancels the current
|
||||||
|
turn over the stream-json control channel. The escape hatch from a
|
||||||
|
runaway turn.
|
||||||
|
- Native permission & AskUserQuestion prompts (T-166, T-175, T-176, T-179,
|
||||||
|
D-78) — the composer becomes a prompt: Allow / Allow-and-don't-ask-again /
|
||||||
|
Deny showing the command, or an AskUserQuestion option picker (single or
|
||||||
|
stepped, with "Other" free-text and per-choice notes). Closes the tmux
|
||||||
|
prompt gap.
|
||||||
|
- Conversation message cards (T-173) — every turn in the Claude pane now
|
||||||
|
renders through one card template with a copy button on hover and a
|
||||||
|
collapse/expand caret for tool calls, results, and thinking.
|
||||||
|
- Collapsed-by-default tool cards (T-177) — multi-line tool calls and
|
||||||
|
results start collapsed behind a one-line summary; one-line output stays
|
||||||
|
inline so a caret never hides a single line.
|
||||||
|
- Prompted tool calls are quieter in the log (T-179) — a permission request
|
||||||
|
shows the command in the prompt, not as a raw tool-use card; once decided
|
||||||
|
it collapses to a one-line summary with a green (approved) or red (denied)
|
||||||
|
border, and the result is kept. AskUserQuestion's tool-use + result are
|
||||||
|
replaced by the logged answer.
|
||||||
|
- Harness-injected messages are de-emphasized (T-178) — skill loads,
|
||||||
|
slash-command expansions, and system reminders (Claude's `isSynthetic`
|
||||||
|
messages) render as a muted, collapsed "context" card instead of a blue
|
||||||
|
"you" message, since they weren't typed by the user.
|
||||||
|
- Claude meta sidebar (T-141, T-157) — an always-pickable left-panel tab
|
||||||
|
showing Claude activity (the latest day's messages/sessions/tool-calls
|
||||||
|
plus lifetime totals, from `stats-cache.json`) and, when a tmux team is
|
||||||
|
running, a roster of its members (colour · name · agent type · model)
|
||||||
|
with each member's live permission-mode and context once it's active.
|
||||||
|
- Claude session storage view (T-148) — the `claude.session-storage`
|
||||||
|
command opens a modal listing the workspace's session transcripts with
|
||||||
|
their on-disk sizes and a total, each removable with a two-click
|
||||||
|
confirm. User-driven only; clide never deletes transcripts on its own.
|
||||||
|
- Slash-command typeahead in the Claude composer (T-152) — typing `/`
|
||||||
|
(anywhere in the message, not just at the start) pops a list of
|
||||||
|
matching commands and skills sourced from the Claude environment;
|
||||||
|
arrow keys move, Enter/Tab completes, Escape dismisses.
|
||||||
|
- Claude environment service (T-151) — clide reads skills, commands,
|
||||||
|
settings, and permissions from `~/.claude` and the repo's `.claude`
|
||||||
|
(layered), and caches the slash-command list per claude version. Backs
|
||||||
|
the typeahead and command-aware send.
|
||||||
|
- Per-session status in the bottom status bar (T-145, T-150, T-154) — the
|
||||||
|
active Claude pane shows its model · permission mode (accept-edits /
|
||||||
|
plan / …) · context-token count · configured skills count, swapping to
|
||||||
|
the focused pane on tab switch and clearing on blur. Long status text
|
||||||
|
marquee-scrolls within the slot.
|
||||||
|
- tmux agent teams surface as native teammate tiles (T-139, T-140) —
|
||||||
|
when a Claude team is running, each teammate shows as a live
|
||||||
|
conversation tile beside the lead in a grid that wraps 1→2→3 columns,
|
||||||
|
with a resizable split. Identity and lifecycle come from the team
|
||||||
|
config; per-teammate content streams over the MessageBus.
|
||||||
|
- Native composer in the Claude pane (T-138) — type below the
|
||||||
|
conversation and press Enter to send (Shift+Enter for a newline).
|
||||||
|
Submits via the tmux server (bracketed paste + Enter), so input
|
||||||
|
reaches Claude even when no tmux client is attached; multi-line goes
|
||||||
|
as one message.
|
||||||
|
- File and image paste in the composer (T-138, T-142) — Ctrl/Cmd+V of a
|
||||||
|
copied file or clipboard image adds a removable chip (image thumbnail
|
||||||
|
or file icon) above the input; on send its `@path` is appended to the
|
||||||
|
message. Plain text pastes inline. Backed by a native `clide/clipboard`
|
||||||
|
channel (GTK + macOS).
|
||||||
|
- Claude pane renders natively from the transcript (T-137, D-75) — the
|
||||||
|
conversation shows as native cards (user / assistant markdown /
|
||||||
|
thinking / tool-use / result) instead of a terminal, with text
|
||||||
|
selection + copy across cards. Claude still runs in tmux; the terminal
|
||||||
|
builtin stays for general use.
|
||||||
|
- Multi-file editor tabs — the editor pane now shows one tab per open
|
||||||
|
buffer (filename + a dot when unsaved) via the shared tab strip;
|
||||||
|
opening a second file no longer replaces the first. Click a tab to
|
||||||
|
switch, × to close. Backed by the daemon's existing multi-buffer
|
||||||
|
model.
|
||||||
|
- Typed IPC command-schema framework (T-119/T-120, D-74) — commands
|
||||||
|
register an argument schema beside their handler; the dispatcher
|
||||||
|
normalises argv into named args, coerces types, and validates
|
||||||
|
(charset, leading-dash, ranges, caps) before the handler runs.
|
||||||
|
- `clide panel resize <slot>` CLI verb (T-119) — set an absolute size
|
||||||
|
with `--to` or nudge with `--by`; `editor` targets the split ratio.
|
||||||
|
Completes user/Claude parity (D-6) with T-111's keyboard resize.
|
||||||
|
- Unix-domain IPC socket server in the Flutter app (T-99 / T-124).
|
||||||
|
Per-workspace path (D-70: `$XDG_RUNTIME_DIR/clide/<hash>.sock` on
|
||||||
|
Linux, `~/Library/Caches/clide/<hash>.sock` on macOS). 0600 socket
|
||||||
|
+ 0700 parent (D-71). Multi-connection accept loop with serial
|
||||||
|
dispatch through `DaemonDispatcher` (D-72). Foundation for the C
|
||||||
|
`clide` client (T-126) and MCP (T-130). No client yet — testable
|
||||||
|
via `socat - UNIX-CONNECT:$SOCK`.
|
||||||
|
- argv→IpcRequest translator (`lib/src/cli/argv_to_request.dart`) —
|
||||||
|
parses `clide SUBSYSTEM VERB [pos...] [--flag] [-- passthrough]` and
|
||||||
|
the umbrella commands (`status`, `tail`, `version`, `ping`) per D-6
|
||||||
|
into the wire envelope. Pure Dart; lets the C client (T-126) stay a
|
||||||
|
dumb pipe (T-99 / T-125).
|
||||||
|
- `DaemonClient.reconnectAt(newPath)` — swap an active client onto a
|
||||||
|
different socket without restart (project switch in T-127).
|
||||||
|
- Event streaming over the IPC socket (T-99 / T-129) — `clide tail
|
||||||
|
--events [--filter X]` opens a long-lived subscription, replays up
|
||||||
|
to 16 recent matching events per subsystem (D-6), and streams new
|
||||||
|
ones as JSON lines. C client loops on `data.streaming` ack. Slow /
|
||||||
|
broken subscribers drop themselves without blocking the bus.
|
||||||
|
- MCP server over HTTP+SSE (T-99 / T-130, per D-68 / D-73). Localhost
|
||||||
|
HTTP listener advertises via `$HOME/.claude/ide/<pid>.lock` so
|
||||||
|
Claude Code's `/ide` discovers it. JSON-RPC 2.0 with the two
|
||||||
|
minimum `/ide` tools shipped as stubs
|
||||||
|
(`mcp__ide__getDiagnostics`, `mcp__ide__executeCode`); real
|
||||||
|
implementations follow.
|
||||||
|
- C `clide` shell client at `native/clide-cli/clide.c`. Walks CWD up
|
||||||
|
to the git root, hashes to the per-workspace socket (D-70), ships
|
||||||
|
argv. `make clide-cli` builds it; on PATH, `clide status` works
|
||||||
|
from any clide-workspace directory once the app is up (T-99,
|
||||||
|
T-126).
|
||||||
|
- Startup project picker — clide now opens to the welcome screen by
|
||||||
|
default instead of auto-opening the last project. A per-row
|
||||||
|
"always open this project on launch" checkbox in welcome's RECENT
|
||||||
|
list sets a sticky-startup flag; if exactly one project has it,
|
||||||
|
that one opens directly. Two or more, or none ⇒ picker (T-115).
|
||||||
|
- CONTRIBUTING.md "Running clide from the shell" section — documents
|
||||||
|
the shell verbs, exit-code contract per D-68, and Claude Code
|
||||||
|
`/ide` MCP discovery via `~/.claude/ide/<pid>.lock` (T-131).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The empty Claude pane now shows a native startup banner — clide logo,
|
||||||
|
session role, workspace, and tmux status — instead of a bare "Waiting
|
||||||
|
for Claude…" (T-149).
|
||||||
|
- User and Claude turns in the conversation now render as distinct
|
||||||
|
accent-striped cards over a filled background — your prompts in the
|
||||||
|
theme focus colour, Claude's replies in Claude's brand orange
|
||||||
|
(T-143, T-144).
|
||||||
|
- Claude conversation content now flows through the kernel MessageBus —
|
||||||
|
a reader tails the transcript and publishes items; the pane subscribes.
|
||||||
|
Decouples reading from rendering so the upcoming team panels can show
|
||||||
|
one lead plus a tile per teammate (T-137).
|
||||||
|
- In-process IPC dispatch swapped for socket loopback (T-127). The
|
||||||
|
Flutter UI's `DaemonClient` now talks to its own `IpcServer` over
|
||||||
|
the same per-workspace Unix socket the C `clide` client uses — one
|
||||||
|
transport, one contract.
|
||||||
|
- D-56 / D-68 amended with implementation notes — both decisions now
|
||||||
|
link out to T-99's eight slices (T-124–T-131) and the D-70/71/72/73
|
||||||
|
records they spawned (T-131).
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `lib/kernel/src/ipc/in_process.dart` (`InProcessClient`) — replaced
|
||||||
|
by the socket-loopback `DaemonClient` (T-127).
|
||||||
|
- `lib/kernel/src/backend.dart`, `lib/kernel/src/backend_entry.dart`,
|
||||||
|
`lib/kernel/src/ipc/isolate_client.dart` — the third unused IPC
|
||||||
|
path (a backend-isolate model that was never wired through). Only
|
||||||
|
the socket model survives now (T-128).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Claude pane is no longer dead-on-arrival when resuming a session (T-161).
|
||||||
|
The primary pane (and `/resume`) relaunched Claude with `--session-id
|
||||||
|
<existing-id>`, which Claude rejects as "already in use" — so the pane had
|
||||||
|
no live backend and typed input vanished. clide now uses `--resume` for an
|
||||||
|
existing session and `--session-id` only for a brand-new one.
|
||||||
|
- Claude pane no longer floods the console with "markNeedsBuild called
|
||||||
|
during build" (T-159) — a focused pane surfacing its status-bar widget
|
||||||
|
now defers the notification out of the build phase instead of rebuilding
|
||||||
|
the status item mid-build.
|
||||||
|
- The git / tickets / decisions / pql / problems tabs no longer log
|
||||||
|
`i18n: namespace not registered` on boot (T-155). An extension's
|
||||||
|
localized tab title is now loaded automatically on activation, and the
|
||||||
|
five missing catalogs were added.
|
||||||
|
- Slash commands sent from the Claude composer now actually run (T-153).
|
||||||
|
Recognised commands are delivered as typed input so Claude's TUI parses
|
||||||
|
them; other input (and stray leading slashes like a `/tmp` path) stays
|
||||||
|
bracketed-pasted as literal text.
|
||||||
|
- `/clear` is now handled by clide — it resets the Claude pane to a fresh,
|
||||||
|
empty session — instead of being forwarded to Claude Code, whose `/clear`
|
||||||
|
forked to a new session clide couldn't follow and left the pane
|
||||||
|
unresponsive (T-156).
|
||||||
|
- `/resume` is now handled by clide too (T-156): it opens a picker of the
|
||||||
|
workspace's past sessions — each labelled by its first … last user message
|
||||||
|
and when it was last active — and re-binds the pane to the chosen one,
|
||||||
|
instead of forwarding Claude Code's session-forking `/resume`.
|
||||||
|
- Claude secondary panes no longer flash a false "session exited" while
|
||||||
|
the session is alive (a transient tmux client exit is now verified
|
||||||
|
against the live session), and the tab and banner agree on the label
|
||||||
|
("session N") (T-149).
|
||||||
|
- Claude pane no longer gets stuck on "Waiting for Claude…" when its
|
||||||
|
session can't be bound (T-147) — e.g. a session left over from before
|
||||||
|
session-id binding, or a fresh machine. It now retires that stale
|
||||||
|
clide session and starts a clean one. Only clide's own `-L clide`
|
||||||
|
sessions are touched (never a terminal Claude, never transcript files).
|
||||||
|
- Secondary Claude tabs showed the primary's conversation instead of
|
||||||
|
their own (T-146). Each pane now binds to its own session via
|
||||||
|
`claude --session-id`, so concurrent sessions in one workspace stop
|
||||||
|
colliding on the newest transcript — the primary keeps a stable id
|
||||||
|
(resumes), secondaries get a fresh one (clean session).
|
||||||
|
- Claude pane no longer freezes the app on open — the transcript reader
|
||||||
|
caps its initial read to the recent tail, parses off the UI isolate,
|
||||||
|
and coalesces view notifications into one rebuild per burst (T-137).
|
||||||
|
- Daemon-not-connected on startup — panels and the Claude pane raced
|
||||||
|
the socket loopback. Requests now wait briefly for an in-flight
|
||||||
|
connection, the server isn't restarted for the same workspace, and
|
||||||
|
the client connects once instead of twice.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
## [2.1.0] — 2026-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Contrast gate split — baseline `canonicalPairs` every theme passes,
|
||||||
|
strict `extendedPairs` (muted/status/syntax/focus-border) gated to
|
||||||
|
`-hc` / `-cb` variants. Ships `clide-hc`, `midnight-hc`, `paper-hc`,
|
||||||
|
`terminal-hc` siblings of the named themes (D-69, T-114, T-118).
|
||||||
|
- Pre-push coverage gate — `make push-check` runs `ci/coverage_gate.sh`,
|
||||||
|
which fails if total line coverage drops below `coverage_floor:` in
|
||||||
|
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
|
||||||
|
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
|
||||||
|
`## [Unreleased]` bullet over 60 words. Enforces the Keep-a-Changelog
|
||||||
|
conciseness rule in the git-commit skill.
|
||||||
|
- Keymap layer (`KeymapService`) — typed Intents, YAML presets,
|
||||||
|
VS-Code-style when-clauses, layered preset → user file → settings
|
||||||
|
overlay. Default preset ships; vim/vscode/jetbrains unblocked
|
||||||
|
(T-117, supersedes T-110).
|
||||||
|
- Keyboard operability — `ClideTappable` is now Tab-focusable with a
|
||||||
|
focus ring and Enter/Space activation; `ClidePalette` adds arrow
|
||||||
|
nav, Escape dismiss, and selection highlight (T-100).
|
||||||
|
- Panel-to-panel focus traversal — each `SlotHost` wraps in a
|
||||||
|
`FocusScope` + `FocusTraversalGroup`; `F6` / `Shift+F6` cycle
|
||||||
|
sidebar → workspace → context. `FocusTracker` integrates with
|
||||||
|
Flutter focus rather than paralleling it (T-105).
|
||||||
|
- Event-driven test waits — PTY + watcher tests await stream events
|
||||||
|
instead of fixed sleeps; `onTimeout` callbacks now `fail()` loudly
|
||||||
|
with diagnostic context. `RecordingEventSink` exposes a broadcast
|
||||||
|
stream for the same pattern (T-108).
|
||||||
|
- Code-quality cleanups — `TreeSitterLib` exposes a last-error
|
||||||
|
diagnostic instead of swallowing dlopen failures; `ExtensionManager`
|
||||||
|
surfaces a `failedExtensions` map for UI degradation; PTY constants
|
||||||
|
consolidated in `libc.dart` + `PosixErrno`; `test_app.dart` gated
|
||||||
|
behind `kDebugMode` (T-112).
|
||||||
|
- Test sweep — `keybindings`, `toolchain_paths`, and several
|
||||||
|
`widgets/src/` primitives (tooltip, palette, multitab, markdown).
|
||||||
|
- `tree_sitter_service` sweep — fake-FFI + real-library smoke,
|
||||||
|
17% → 96%. Crosses the 95% global target (T-91).
|
||||||
|
- Staged `dart doc` CI job — generates and uploads an HTML API
|
||||||
|
reference for the public `lib/` surface. The step wraps
|
||||||
|
`dart doc --validate-links` and grep-fails the build on any warning.
|
||||||
|
Inert with the rest of the workflow until Gitea Actions activates.
|
||||||
|
- Mouse wheel scrolling in Claude pane — converts scroll events to
|
||||||
|
PgUp/PgDown so TUI apps scroll their history naturally.
|
||||||
|
- Welcome screen Tips card — six common keybindings shown below the
|
||||||
|
START / RECENT row when the viewport is tall enough.
|
||||||
|
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||||
|
N runtime tab instances of the same kind. Generic over a payload
|
||||||
|
type, supports pinned/non-closeable tabs, drag-reorder, close × on
|
||||||
|
hover, and an optional `+` add button.
|
||||||
|
- `MultitabPane.keepAlive` mode — entry bodies stay mounted via
|
||||||
|
IndexedStack so switching tabs preserves their state (PTY
|
||||||
|
connections, scroll position, etc.).
|
||||||
|
- `CONTRIBUTING.md` — human-addressed contributor guide covering
|
||||||
|
clone / build / test / DQR / tickets / commit conventions. The
|
||||||
|
`[Unreleased]` section is reorganised to one subsection per kind
|
||||||
|
per Keep a Changelog 1.1.0 (T-109).
|
||||||
|
- `make verify` — no-tests sweep (analyze + format + decisions +
|
||||||
|
changelog gate). For mid-edit checks; `make push-check` stays the
|
||||||
|
full pre-push pipeline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Terminal mouse wheel forwards as proper xterm wheel-button escapes
|
||||||
|
when the inner program declares a mouse mode (?1000h / ?1002h /
|
||||||
|
?1003h, optionally +?1006h SGR). Falls back to PgUp/PgDown only
|
||||||
|
when no mouse mode is active. vim mouse=a / htop / less mouse modes
|
||||||
|
now react to the wheel (T-74).
|
||||||
|
- `lib/src/terminal/` cleaned to the project bar — commented-out
|
||||||
|
`print()` debugging stubs stripped from `custom_text_edit.dart`,
|
||||||
|
stale TODOs in `parser.dart` + `keytab.dart` replaced with clear
|
||||||
|
"not implemented" notes (G2/G3 charsets, VT52 records), and the
|
||||||
|
one `// ignore: invalid_use_of_protected_member` in
|
||||||
|
`terminal_view.dart` gets an inline reason explaining why
|
||||||
|
TerminalView owns its own ShortcutManager. Parser split deferred
|
||||||
|
to T-123 (T-107).
|
||||||
|
- Window-control close-button red, white close glyph, and palette
|
||||||
|
ambient shadow are now tokens (`windowControl.closeHover*`,
|
||||||
|
`shadow.ambient`) instead of hard-coded hex. Light themes get a
|
||||||
|
softer ink-tinted shadow (T-114).
|
||||||
|
- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows
|
||||||
|
up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114).
|
||||||
|
- `make gen-build-info` bakes `lib/src/build_info.g.dart` (name,
|
||||||
|
tagline, version, repository, commit, date) and rewrites
|
||||||
|
`assets/licenses.yaml` `self.version:` from `pubspec.yaml` on every
|
||||||
|
build/run/test target. Welcome banner / status line / window title
|
||||||
|
/ project switcher labels all read from those constants — one
|
||||||
|
source of truth, no manual sync, no `--dart-define` plumbing.
|
||||||
|
New `tagline:` field in pubspec for the short user-facing line
|
||||||
|
(welcome subtitle, future web meta).
|
||||||
|
- Panel splitters (sidebar / context / editor-split) are tab-focusable;
|
||||||
|
arrow keys nudge by 10 px, Shift+arrow by 50 px (2% / 10% for the
|
||||||
|
editor split). Exposed as slider Semantics nodes so screen readers
|
||||||
|
announce the current size. CLI verb deferred to T-99 (T-111).
|
||||||
|
- Changelog gate is binary — dropped the soft 40-word warning, kept
|
||||||
|
the 60-word hard cap. Warnings that never blocked just normalised
|
||||||
|
drift.
|
||||||
|
- PTY spawning uses `posix_openpt` + `posix_spawn` instead of
|
||||||
|
`forkpty` — closes a ~5% deadlock window in the multithreaded Dart
|
||||||
|
VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at
|
||||||
|
spawn time. Drops the `libutil.so.1` dependency.
|
||||||
|
- Coverage floor ratcheted to 95% — D-66 target hit.
|
||||||
|
- `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset
|
||||||
|
loaders for fake-driven tests; production paths unchanged.
|
||||||
|
- Tidied test imports flagged by `unnecessary_import`.
|
||||||
|
- `README.md` rewritten to match current architecture;
|
||||||
|
`docs/initial-plan.md` bannered as historical; new
|
||||||
|
`docs/architecture.md` describes today's shape (T-101).
|
||||||
|
- `SchedulerService._stopTicker` now awaits the in-flight isolate spawn
|
||||||
|
before killing — closes the same race shape we fixed in PTY (T-106).
|
||||||
|
- `make push-check-full` added — runs `push-check` plus integration +
|
||||||
|
smoke for pre-release checks. Integration tests skip the hanging
|
||||||
|
theme_picker case until that's fixed (T-103, T-116).
|
||||||
|
- Governance bookkeeping: D-66 amended (floor at `coverage_floor:` in
|
||||||
|
`pubspec.yaml`); `licenses.yaml` reconciled with `pubspec.yaml`;
|
||||||
|
Q-1/Q-2/Q-3/Q-25 triaged; `.claude/skills/README.md` inventory
|
||||||
|
added; `--no-fatal-infos` dropped from `ci/test.sh` (T-113).
|
||||||
|
- Terminal panes render bold attributes with a real bold weight —
|
||||||
|
bundled JetBrainsMono Bold + BoldItalic registered with the
|
||||||
|
`JetBrainsMono` family at `weight: 700`. The painter's bold
|
||||||
|
suppression workaround is gone.
|
||||||
|
- Claude pane uses `MultitabPane` for primary + secondaries — drops
|
||||||
|
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
|
||||||
|
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
|
||||||
|
`clideInset*` for paddings, `clideGap*` for sibling distances,
|
||||||
|
`clideIcon*` / `clideControlHeight` for control sizes.
|
||||||
|
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
|
||||||
|
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
|
||||||
|
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
|
||||||
|
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
|
||||||
|
three transitive dependencies (xterm, quiver, zmodem).
|
||||||
|
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
|
||||||
|
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
|
||||||
|
- Claude pane spawns `claude` directly inside tmux with
|
||||||
|
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode.
|
||||||
|
- PTY read buffer increased from 4 KB to 64 KB.
|
||||||
|
- Terminal view 2 px padding on all sides.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56
|
||||||
|
dissolution. The separate daemon process was dissolved on 2026-04-23
|
||||||
|
but the entry point and socket server class were never deleted.
|
||||||
|
Gone now, along with orphaned tests, stale i18n strings, and
|
||||||
|
"start `clide --daemon`" error messages.
|
||||||
|
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
|
||||||
|
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
|
||||||
|
C helper and its Dart wiring were never cleaned up. Removed from
|
||||||
|
toolchain resolution, `ToolCheck` gate, backend serialization,
|
||||||
|
testmode harness, CI scripts, Makefile, and sandbox entitlements.
|
||||||
|
D-5 amended to record the retirement.
|
||||||
|
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
|
||||||
|
of geometric shapes differs between macOS and Linux even with the
|
||||||
|
Ahem font. Replaced with platform-keyed goldens (`goldens/linux/`,
|
||||||
|
`goldens/macos/`).
|
||||||
|
- Bold JetBrains Mono font registration that prevented glyph-width
|
||||||
|
mismatch in terminal rendering — superseded by the Bold/BoldItalic
|
||||||
|
re-registration above.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Welcome status line no longer overflows on narrow viewports —
|
||||||
|
whole-row `FittedBox(scaleDown)` instead of fixed sibling widths.
|
||||||
|
Inline `fontSize:` literals replaced with the typography
|
||||||
|
constants. Version label reads `clideVersion` so the status line
|
||||||
|
stays in sync with `pubspec.yaml` (T-116).
|
||||||
|
- Integration test `theme_picker_test.dart` no longer deadlocks —
|
||||||
|
was `await`ing `services.commands.execute('theme.pick')` whose
|
||||||
|
Future doesn't complete until the dialog is dismissed. Now
|
||||||
|
fire-and-forget around `pumpAndSettle` (T-116).
|
||||||
|
- `TerminalView.onTapUp` now actually fires on primary tap — was
|
||||||
|
wired to a dead code path (T-93). Dead `onTapUp` surface on
|
||||||
|
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
|
||||||
|
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
|
||||||
|
Real trigger: `Terminal.eraseDisplayAbove` with the cursor at
|
||||||
|
column 0 — common after `ESC[H\x1b[1J` (home + erase-above).
|
||||||
|
- Terminal selections no longer vanish when resizing narrower —
|
||||||
|
reflow's tail-anchor handler left anchors detached past the
|
||||||
|
trimmed range. Common triggers: Ctrl+A then resize, drag past a
|
||||||
|
partially-filled line (T-92).
|
||||||
|
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
|
||||||
|
anchors due to concurrent list modification during iteration —
|
||||||
|
iteration now snapshots the list first (T-91).
|
||||||
|
- Closing a secondary Claude pane tab now kills its tmux session on
|
||||||
|
the clide socket, honouring D-41's lifecycle. Previously
|
||||||
|
`pane.close` only killed the ptyc-spawned tmux client.
|
||||||
|
- Cold-start reap: every clide launch kills any leftover secondary
|
||||||
|
tmux sessions for the current repo before spawning new ones, so
|
||||||
|
D-41's "secondary numbering resets between runs" holds.
|
||||||
|
- `claude.kill-all-sessions` command now actually kills the
|
||||||
|
server-side tmux sessions for the repo, not just the panes.
|
||||||
|
- Terminal cell grid no longer drifts on bold text — bold rendering
|
||||||
|
is suppressed at the painter level since synthetic bold (with no
|
||||||
|
Bold.ttf registered) shifts glyph advance widths.
|
||||||
|
- PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
|
||||||
|
instead of swallowing. `execve` failures write a diagnostic to the
|
||||||
|
slave before `_exit`. `NativePty.write` and `PtySession.write`
|
||||||
|
loop on short writes; both throw `PtyException` on hard errors.
|
||||||
|
- PTY teardown order fixed — kill child first so the master fd
|
||||||
|
returns EOF, await reader isolate exit, then close the fd.
|
||||||
|
- Reader isolate spawn errors in `NativePty` / `PtySession` are now
|
||||||
|
surfaced via the output stream instead of silently dropped.
|
||||||
|
`_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw.
|
||||||
|
- IPC server hardening: per-request 60s timeout (configurable),
|
||||||
|
broadcast/response write failures logged instead of swallowed,
|
||||||
|
client dropped on response-write failure, and the stale-socket
|
||||||
|
retry now probes for a live daemon before unlinking the socket.
|
||||||
|
- `pane.spawn` and `editor.open` map POSIX errno values to actionable
|
||||||
|
IPC error kinds (ENOENT → `not_found`, EACCES/EPERM → `user_error`,
|
||||||
|
EISDIR/ENOTDIR/EEXIST → distinct kinds, EMFILE/ENFILE →
|
||||||
|
`tool_error` with an fd-limit hint).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- IPC: `git.checkout`, `git.push` reject branch/remote args starting
|
||||||
|
with `-` (closes the `--upload-pack=...` argv-injection vector).
|
||||||
|
`files.read` rejects files over 10 MB. `git.log` caps `count` at
|
||||||
|
1000; `git.diff` / `git.stage` cap paths at 256 (T-104).
|
||||||
|
- Toolchain no longer resolves the dugite git binary against the open
|
||||||
|
workspace — a malicious repo could plant `native/dugite/bin/git`
|
||||||
|
and clide would run it on auto-fired `git.status`. Dugite now
|
||||||
|
resolves against the install dir + `CLIDE_DUGITE_DIR` env override
|
||||||
|
only (T-98).
|
||||||
|
- `files.read` and `files.ls` reject symlinks whose targets live
|
||||||
|
outside the workspace — closes a path-safety bypass via in-repo
|
||||||
|
symlinks (T-102).
|
||||||
|
- `files.read` and `files.ls` reject paths that resolve outside the
|
||||||
|
workspace root. Previously a relative path containing `..` could
|
||||||
|
read arbitrary files via path traversal.
|
||||||
|
|
||||||
## [2.0.0] — 2026-05-03
|
## [2.0.0] — 2026-05-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -4,30 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What clide is
|
## What clide is
|
||||||
|
|
||||||
A Flutter desktop IDE for Claude Code. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
|
An IDE for Claude Code CLI. Single Flutter package at the repo root.
|
||||||
|
|
||||||
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56).
|
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
|
||||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||||
- **`ptyc/`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
|
|
||||||
|
|
||||||
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||||
|
|
||||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisions/`](decisions/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`decisions/README.md`](decisions/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
|
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
These are load-bearing. Violating any means the design is wrong, not the rule.
|
These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||||
|
|
||||||
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
|
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
|
||||||
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). The CLI surface for Claude is a thin C client (ptyc peer).
|
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it).
|
||||||
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](decisions/architecture.md#d-1-cli-first-not-mcp).
|
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](governance/decisions/architecture.md#d-1-cli-first-not-mcp).
|
||||||
- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`posix_openpt` + `posix_spawn`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](governance/decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
||||||
- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages.
|
- **Own the rendering stack.** PTY (via Dart FFI), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages.
|
||||||
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](decisions/architecture.md#d-6-cli-and-event-surface-contract).
|
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract).
|
||||||
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](governance/decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
||||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. See [`D-4`](decisions/architecture.md#d-4-ignore-file-strategy).
|
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. See [`D-4`](governance/decisions/architecture.md#d-4-ignore-file-strategy).
|
||||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions as `Q-NNN`. Rejected alternatives as `R-NNN`. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`decisions/README.md`](decisions/README.md).
|
- **Decision discipline.** All architectural choices live in `governance/decisions/<domain>.md` as `D-NNN` records. Open questions as `Q-NNN` under `governance/questions/<domain>.md`. Rejected alternatives as `R-NNN` under `governance/rejected/<domain>.md`. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`governance/README.md`](governance/README.md).
|
||||||
|
- **No pre-existing excuse.** Solo-dev repo — every failure encountered is yours to fix, regardless of who introduced it. If `make test` is red, a golden is broken, or `flutter analyze` shows a warning when you start working, the order is: **fix it first, then your work**. If you genuinely can't fix it in scope (separate ticket, large sweep, missing context), stop and surface it before continuing — don't push on top of broken state. "It was already broken" is not a reason to add more on top.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -45,9 +45,8 @@ lib/
|
|||||||
test/ # All tests (core subsystems + widgets + goldens + a11y)
|
test/ # All tests (core subsystems + widgets + goldens + a11y)
|
||||||
assets/ # Fonts, themes, grammars, licenses, logo
|
assets/ # Fonts, themes, grammars, licenses, logo
|
||||||
linux/, macos/, web/ # Flutter platform directories
|
linux/, macos/, web/ # Flutter platform directories
|
||||||
ptyc/ # C PTY helper
|
native/ # Vendored native libs (libtree-sitter.so, dugite)
|
||||||
native/ # Vendored native libs (libtree-sitter.so)
|
governance/ # D/Q/R records (decisions/, questions/, rejected/ subdirs)
|
||||||
decisions/ # D/Q/R records
|
|
||||||
docs/ # Design docs, wireframes
|
docs/ # Design docs, wireframes
|
||||||
legacy/ # Python Textual clide v1.2 (frozen)
|
legacy/ # Python Textual clide v1.2 (frozen)
|
||||||
```
|
```
|
||||||
@@ -55,8 +54,8 @@ legacy/ # Python Textual clide v1.2 (frozen)
|
|||||||
## Dependencies & supply chain
|
## Dependencies & supply chain
|
||||||
|
|
||||||
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
||||||
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
|
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](governance/decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
|
||||||
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
|
- **Native deps (dugite, libtree-sitter):** vendored in `native/`, pinned by SHA. Bumps follow the same advisory-review + `licenses.yaml` rule.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -70,7 +69,6 @@ make test-a11y # accessibility contract tests
|
|||||||
make test-integration# real app boot integration tests
|
make test-integration# real app boot integration tests
|
||||||
make build-linux # flutter build linux
|
make build-linux # flutter build linux
|
||||||
make build-macos # flutter build macos
|
make build-macos # flutter build macos
|
||||||
make ptyc-build # build the ptyc PTY-spawn helper
|
|
||||||
make push-check # pre-push gate: decisions + core + fast tests + a11y
|
make push-check # pre-push gate: decisions + core + fast tests + a11y
|
||||||
make hooks # install the repo's git hooks (one-time setup)
|
make hooks # install the repo's git hooks (one-time setup)
|
||||||
make clean # remove build artefacts
|
make clean # remove build artefacts
|
||||||
@@ -78,10 +76,14 @@ make clean # remove build artefacts
|
|||||||
|
|
||||||
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
|
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
|
||||||
|
|
||||||
|
## Git workflow
|
||||||
|
|
||||||
|
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
||||||
|
|
||||||
## Changelog discipline
|
## Changelog discipline
|
||||||
|
|
||||||
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `pubspec.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
|
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `pubspec.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
Open questions live under [`decisions/questions-*.md`](decisions/questions.md).
|
Open questions live under [`governance/questions/`](governance/questions/).
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Contributing to clide
|
||||||
|
|
||||||
|
clide is an IDE for the Claude Code CLI, built as a single Flutter
|
||||||
|
package at the repo root. This guide is for people working on clide
|
||||||
|
itself.
|
||||||
|
|
||||||
|
For day-to-day model-driven work in the codebase, see
|
||||||
|
[`CLAUDE.md`](CLAUDE.md) — it documents the same guardrails from the
|
||||||
|
agent's point of view and is the place to look for "why is the code
|
||||||
|
shaped like this?"
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone git@github.com:postmeridiem/clide.git
|
||||||
|
cd clide
|
||||||
|
make hooks && flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
`make hooks` points `core.hooksPath` at [`.githooks/`](.githooks). The
|
||||||
|
load-bearing one is **pre-push** (runs `make push-check`); the rest
|
||||||
|
(pre-commit, post-checkout/merge/rewrite) are housekeeping. Flutter must
|
||||||
|
be on the stable channel and on `$PATH`.
|
||||||
|
|
||||||
|
If you haven't used [pql](https://github.com/postmeridiem/pql) before,
|
||||||
|
install it and run `pql init` once in the repo root. pql is a hard
|
||||||
|
dependency for the governance + ticket workflow described below.
|
||||||
|
|
||||||
|
## The commands you'll actually use
|
||||||
|
|
||||||
|
```
|
||||||
|
make run # launch the desktop app
|
||||||
|
make verify # no-tests sweep — analyze + format + decisions + changelog gate
|
||||||
|
make test # fast dev loop — analyze + format + unit + widget + golden, NO coverage, parallel (~20s)
|
||||||
|
make test-coverage # same suite WITH coverage (writes coverage/lcov.info; used by the gate)
|
||||||
|
make test-a11y # WCAG-AA contrast + keyboard traversal contracts
|
||||||
|
make push-check # the pre-push gate; what CI runs (runs test-coverage, not the no-coverage test)
|
||||||
|
make build-linux # release artefact for the host platform
|
||||||
|
```
|
||||||
|
|
||||||
|
`make verify` is the lightweight "are the gates green?" check for
|
||||||
|
mid-edit iteration. `make push-check` is the full pre-push pipeline
|
||||||
|
(verify + every test suite + coverage gate).
|
||||||
|
|
||||||
|
`make` with no target prints the full list. `make test-integration`
|
||||||
|
boots a real app process and is slow; reserve it for the rare change
|
||||||
|
that touches startup wiring.
|
||||||
|
|
||||||
|
The pre-push hook runs `make push-check` automatically. Don't bypass
|
||||||
|
it with `--no-verify` — fix the underlying issue and create a new
|
||||||
|
commit. Pre-push includes:
|
||||||
|
|
||||||
|
- `flutter analyze` (zero warnings)
|
||||||
|
- `dart format --set-exit-if-changed`
|
||||||
|
- the core (`dart test`) suite
|
||||||
|
- the unit/widget/golden/a11y suites, instrumented for coverage
|
||||||
|
(`make test-coverage` — the a11y suite runs here, not as a separate pass)
|
||||||
|
- a coverage floor read from `coverage_floor:` in `pubspec.yaml`
|
||||||
|
(currently 95 %; ratchets up only — see
|
||||||
|
[D-66](governance/decisions/testing.md#d-66))
|
||||||
|
- `CHANGELOG.md` `[Unreleased]` bullets ≤ 60 words each
|
||||||
|
|
||||||
|
## The C `clide` shell client
|
||||||
|
|
||||||
|
`clide` (the binary) is a ~250 LOC C program in
|
||||||
|
[`native/clide-cli/clide.c`](native/clide-cli/clide.c) that talks to
|
||||||
|
the running Flutter app's IPC socket so Claude (and you) can drive
|
||||||
|
clide from any shell. It walks CWD up to the workspace's `.git`,
|
||||||
|
computes the same FNV-1a 64-bit hash the Dart side uses (per D-70),
|
||||||
|
opens the per-workspace socket, and sends argv across the wire under
|
||||||
|
a sentinel `_argv` cmd. The argv parser lives in Dart
|
||||||
|
([`lib/src/cli/argv_to_request.dart`](lib/src/cli/argv_to_request.dart)),
|
||||||
|
so the C side stays a dumb pipe.
|
||||||
|
|
||||||
|
Build it with `make clide-cli` — output lands at
|
||||||
|
`native/<platform>/clide` (gitignored). Drop that on your PATH (or
|
||||||
|
symlink) and `clide status` works from any directory inside a clide
|
||||||
|
workspace once the app is running. Standard POSIX + libc only;
|
||||||
|
pure C99; no third-party deps.
|
||||||
|
|
||||||
|
The cross-language hash agreement is load-bearing — if the Dart
|
||||||
|
server and C client disagree on the socket path, every shell
|
||||||
|
invocation fails to connect. The test suite covers it:
|
||||||
|
[`test/ipc/paths_test.dart`](test/ipc/paths_test.dart) pins
|
||||||
|
FNV-1a vectors against the reference, and
|
||||||
|
[`test/cli/clide_cli_e2e_test.dart`](test/cli/clide_cli_e2e_test.dart)
|
||||||
|
compiles the C client and exercises the full round-trip.
|
||||||
|
|
||||||
|
## Running clide from the shell
|
||||||
|
|
||||||
|
Once the desktop app is running and `clide` is on your `$PATH`, every
|
||||||
|
shell inside the workspace can drive it. The argv grammar is fixed by
|
||||||
|
[D-6](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract)
|
||||||
|
— every UI affordance has a CLI counterpart, and every CLI verb has a
|
||||||
|
UI affordance.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
clide status # one-shot snapshot of pane state
|
||||||
|
clide ping # round-trip health check
|
||||||
|
clide tail --events # stream every subsystem event
|
||||||
|
clide tail --events --filter git # stream a single subsystem
|
||||||
|
clide pane focus editor # drive the running app
|
||||||
|
clide panel toggle git # show/hide a panel
|
||||||
|
clide panel resize sidebar --to 320 # absolute width (px)
|
||||||
|
clide panel resize context --by -40 # relative nudge (px)
|
||||||
|
clide panel resize editor --to 0.5 # editor split ratio (0.15–0.70)
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is JSON on stdout, one envelope per line. Streaming verbs
|
||||||
|
(`tail --events`) keep the connection open and emit one event line per
|
||||||
|
push; Ctrl-C cleanly closes the socket. Exit codes follow the pql
|
||||||
|
convention ([D-68](governance/decisions/architecture.md#d-68-cli-and-mcp-surface-contracts)):
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|------------------------------------------------------|
|
||||||
|
| 0 | success |
|
||||||
|
| 64 | user error — bad argv, unknown verb, malformed flag |
|
||||||
|
| 65 | data error — request well-formed but rejected |
|
||||||
|
| 69 | service unavailable — no running clide for this repo |
|
||||||
|
| 70 | internal error — dispatcher threw |
|
||||||
|
|
||||||
|
If `clide` exits 69, the desktop app isn't running for this workspace
|
||||||
|
— start it with `make run` (during development) or launch the
|
||||||
|
installed app pointed at this repo.
|
||||||
|
|
||||||
|
### Claude Code's `/ide` and the MCP server
|
||||||
|
|
||||||
|
The desktop app also runs an MCP companion server on a random
|
||||||
|
localhost port (HTTP + Server-Sent Events, per
|
||||||
|
[D-73](governance/decisions/architecture.md#d-73-mcp-server-transport-over-http-sse)).
|
||||||
|
Discovery follows the Claude Code `/ide` contract: clide writes a
|
||||||
|
JSON descriptor to `~/.claude/ide/<pid>.lock` containing the chosen
|
||||||
|
port, the workspace root, and the protocol version. Claude Code's
|
||||||
|
`/ide` command picks the lock for the workspace it's running in and
|
||||||
|
connects automatically — no manual configuration. The lock is removed
|
||||||
|
on graceful shutdown; stale locks from crashed processes are reaped
|
||||||
|
on the next start. The implementation lives in
|
||||||
|
[`lib/src/ipc/mcp_server.dart`](lib/src/ipc/mcp_server.dart).
|
||||||
|
|
||||||
|
## Decisions, questions, rejected (DQR)
|
||||||
|
|
||||||
|
clide tracks architectural commitments as durable records under
|
||||||
|
[`governance/`](governance/):
|
||||||
|
|
||||||
|
- `decisions/<domain>.md` — confirmed decisions (`D-NNN`)
|
||||||
|
- `questions/<domain>.md` — open questions (`Q-NNN`)
|
||||||
|
- `rejected/<domain>.md` — rejected proposals (`R-NNN`)
|
||||||
|
|
||||||
|
When you make a non-trivial architectural choice, write it down:
|
||||||
|
|
||||||
|
```
|
||||||
|
pql decisions claim D <domain> "short title"
|
||||||
|
```
|
||||||
|
|
||||||
|
This reserves a fresh ID and tells you where to add the record. The
|
||||||
|
[governance/README.md](governance/README.md) explains the format and
|
||||||
|
the recommended domain list.
|
||||||
|
|
||||||
|
Pre-push validates that every `D-NNN` / `Q-NNN` / `R-NNN` link in
|
||||||
|
the docs and code resolves to an actual record (`pql decisions
|
||||||
|
validate`). Broken references fail the build.
|
||||||
|
|
||||||
|
## Tickets
|
||||||
|
|
||||||
|
All non-trivial work is tracked in `pql ticket`:
|
||||||
|
|
||||||
|
```
|
||||||
|
pql ticket list --status in_progress
|
||||||
|
pql ticket show T-NNN[,T-NNN…] # batch form on pql 1.4.33+
|
||||||
|
pql ticket new task "title" --parent T-NNN --priority medium
|
||||||
|
pql ticket status T-NNN in_progress
|
||||||
|
pql ticket status T-NNN done
|
||||||
|
```
|
||||||
|
|
||||||
|
A ticket exists for any change a reviewer might want to ask "why?"
|
||||||
|
about. Bug fixes, refactors, and consultant findings all become
|
||||||
|
tickets before the diff lands. Trivial typo fixes don't need one.
|
||||||
|
|
||||||
|
## Commit conventions
|
||||||
|
|
||||||
|
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||||
|
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||||
|
|
||||||
|
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
||||||
|
(this isn't a Conventional Commits repo — the archived Python
|
||||||
|
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
||||||
|
- One logical change per commit. If the subject needs "and", split it.
|
||||||
|
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||||
|
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||||
|
Removed, Fixed, Security). Keep entries to one or two short
|
||||||
|
sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`.
|
||||||
|
- Co-author trailer:
|
||||||
|
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||||
|
any of the diff.
|
||||||
|
|
||||||
|
Never `--amend` a commit unless explicitly asked. Never force-push
|
||||||
|
to `main`. Never `git add -A` / `git add .` when staging — name
|
||||||
|
files explicitly so stray secrets or build artefacts don't sneak in.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
A release is a single commit:
|
||||||
|
|
||||||
|
1. Move all `## [Unreleased]` entries under a new `## [X.Y.Z] —
|
||||||
|
YYYY-MM-DD` heading.
|
||||||
|
2. Leave an empty `## [Unreleased]` skeleton at the top.
|
||||||
|
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (no `-dev` suffix on
|
||||||
|
the release tag; add it back on the next development commit if
|
||||||
|
you like).
|
||||||
|
4. Commit subject: `release vX.Y.Z`.
|
||||||
|
|
||||||
|
`pubspec.yaml` is the single source of truth for the version — the
|
||||||
|
Makefile reads it for ldflag stamping and the app reads it for build
|
||||||
|
info.
|
||||||
|
|
||||||
|
## What goes where
|
||||||
|
|
||||||
|
- **Bug, feature, sweep:** file/claim a ticket, branch, code, test,
|
||||||
|
commit, push. Push triggers `make push-check`.
|
||||||
|
- **Architectural decision:** `pql decisions claim`, write the
|
||||||
|
record, then file the implementation ticket linked via
|
||||||
|
`decision_ref`.
|
||||||
|
- **Open question:** drop a `Q-NNN` under
|
||||||
|
`governance/questions/<domain>.md`. Triage later.
|
||||||
|
- **Rejected proposal:** drop an `R-NNN` under
|
||||||
|
`governance/rejected/<domain>.md`. Future-you (or a reviewer)
|
||||||
|
will be glad it's written down.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
The public issue tracker lives at
|
||||||
|
<https://github.com/postmeridiem/clide/issues>. The Gitea mirror is
|
||||||
|
read-only.
|
||||||
@@ -19,6 +19,10 @@ else
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||||
|
NAME ?= $(shell awk -F': *' '/^name:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||||
|
TAGLINE ?= $(shell awk -F': *' '/^tagline:/ {sub(/^tagline: *"?/,"",$$0); sub(/"$$/,"",$$0); print; exit}' pubspec.yaml)
|
||||||
|
REPOSITORY ?= $(shell awk -F': *' '/^repository:/ {sub(/^repository: */,"",$$0); print; exit}' pubspec.yaml)
|
||||||
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
@@ -28,11 +32,14 @@ help: ## Show this help.
|
|||||||
# -- app (Flutter) -------------------------------------------------------
|
# -- app (Flutter) -------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run: ## Launch the Flutter desktop app.
|
run: gen-build-info clide-cli ## Launch the Flutter desktop app.
|
||||||
|
# CLIDE_CLI_BIN points the in-app "Install clide command in PATH"
|
||||||
|
# affordance (T-212) at the dev-tree C client; a packaged build finds it
|
||||||
|
# beside the GUI runner in the bundle instead.
|
||||||
ifeq ($(FLUTTER_OS),linux)
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||||
else
|
else
|
||||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
TESTMODE_CATEGORY ?= all
|
TESTMODE_CATEGORY ?= all
|
||||||
@@ -64,7 +71,7 @@ pubget: ## flutter pub get.
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
.PHONY: build-check
|
.PHONY: build-check
|
||||||
build-check: ## Verify native + Dart build compiles (no run).
|
build-check: gen-build-info ## Verify native + Dart build compiles (no run).
|
||||||
ifeq ($(FLUTTER_OS),linux)
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
||||||
else
|
else
|
||||||
@@ -79,20 +86,50 @@ analyze: ## flutter analyze.
|
|||||||
format: ## dart format --set-exit-if-changed.
|
format: ## dart format --set-exit-if-changed.
|
||||||
dart format --set-exit-if-changed .
|
dart format --set-exit-if-changed .
|
||||||
|
|
||||||
|
# The single bake of every build-time fact derived from pubspec.yaml
|
||||||
|
# + git + clock. Runs implicitly as a prereq of every target that
|
||||||
|
# compiles or executes Dart code so nobody has to remember it.
|
||||||
|
#
|
||||||
|
# Outputs:
|
||||||
|
# - lib/src/build_info.g.dart — gitignored, fresh on every build.
|
||||||
|
# Exposes `clideName`, `clideTagline`, `clideVersion`, `clideRepository`
|
||||||
|
# (from pubspec), `clideCommit` (git short SHA), `clideDate` (UTC
|
||||||
|
# now) for the app to read directly.
|
||||||
|
# - assets/licenses.yaml `self.version:` — rewritten in place so the
|
||||||
|
# bundled license manifest never drifts from pubspec. (Tracked in
|
||||||
|
# git; the rewrite is a no-op when in sync.)
|
||||||
|
.PHONY: gen-build-info
|
||||||
|
gen-build-info:
|
||||||
|
@printf '// GENERATED — do not edit. Regenerated by `make` on every\n// build/run/test (see gen-build-info in Makefile). Name / tagline /\n// version / repository come from pubspec.yaml — the single source\n// of truth. Commit + date stamp at run time.\nconst String clideName = '"'"'%s'"'"';\nconst String clideTagline = '"'"'%s'"'"';\nconst String clideVersion = '"'"'%s'"'"';\nconst String clideRepository = '"'"'%s'"'"';\nconst String clideCommit = '"'"'%s'"'"';\nconst String clideDate = '"'"'%s'"'"';\n' "$(NAME)" "$(TAGLINE)" "$(VERSION)" "$(REPOSITORY)" "$(COMMIT)" "$(DATE)" > lib/src/build_info.g.dart
|
||||||
|
@awk -v v="$(VERSION)" '/^self:/ {in_self=1} in_self && /^[[:space:]]+version:/ {sub(/version:.*/, "version: \"" v "\""); in_self=0} {print}' assets/licenses.yaml > assets/licenses.yaml.tmp && mv assets/licenses.yaml.tmp assets/licenses.yaml
|
||||||
|
|
||||||
|
.PHONY: verify
|
||||||
|
verify: gen-build-info analyze format decisions-validate changelog-gate ## No-tests sweep — gen-build-info + analyze + format + decisions-validate + changelog-gate. For mid-edit "are the gates green?" checks; `push-check` is the full pre-push pipeline.
|
||||||
|
|
||||||
|
.PHONY: t
|
||||||
|
t: gen-build-info ## Run one test path with tee'd output. Usage: make t T=test/path/to/spec.dart
|
||||||
|
@mkdir -p test/.test-output
|
||||||
|
@if [ -z "$(T)" ]; then echo "usage: make t T=test/path/to/spec.dart" >&2; exit 2; fi
|
||||||
|
flutter test $(T) 2>&1 | tee test/.test-output/last.log
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## Fast: analyze + format + unit + widget + golden (<60s).
|
test: gen-build-info ## Fast dev loop: analyze + format + unit + widget + golden, NO coverage, parallel (~20s).
|
||||||
ci/test.sh
|
ci/test.sh
|
||||||
|
|
||||||
|
.PHONY: test-coverage
|
||||||
|
test-coverage: gen-build-info ## Same suite WITH coverage → coverage/lcov.info (for the gate / CI). Slower.
|
||||||
|
ci/test.sh --coverage
|
||||||
|
|
||||||
.PHONY: test-core
|
.PHONY: test-core
|
||||||
test-core: ## Core subsystem tests (IPC, PTY, git, pane registry).
|
test-core: gen-build-info ## Core subsystem tests (IPC, PTY, git, pane registry).
|
||||||
ci/test_core.sh
|
ci/test_core.sh
|
||||||
|
|
||||||
.PHONY: test-a11y
|
.PHONY: test-a11y
|
||||||
test-a11y: ## A11y contract (semantic coverage + keyboard + contrast + i18n).
|
test-a11y: gen-build-info ## A11y contract (semantic coverage + keyboard + contrast + i18n).
|
||||||
ci/test_a11y.sh
|
ci/test_a11y.sh
|
||||||
|
|
||||||
.PHONY: test-integration
|
.PHONY: test-integration
|
||||||
test-integration: ## Integration tests (real app boot; xvfb on headless Linux).
|
test-integration: gen-build-info ## Integration tests (real app boot; xvfb on headless Linux).
|
||||||
ci/test_integration.sh
|
ci/test_integration.sh
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
@@ -102,9 +139,13 @@ test-e2e: ## End-to-end Playwright smoke.
|
|||||||
.PHONY: test-all
|
.PHONY: test-all
|
||||||
test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequentially.
|
test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequentially.
|
||||||
|
|
||||||
.PHONY: coverage
|
.PHONY: coverage-gate
|
||||||
coverage: ## flutter test --coverage + lcov summary.
|
coverage-gate: ## Coverage gate — fails if total line % < pubspec.yaml `coverage_floor:` (D-66). Assumes `make test-coverage` ran first.
|
||||||
ci/test_coverage.sh
|
ci/coverage_gate.sh
|
||||||
|
|
||||||
|
.PHONY: changelog-gate
|
||||||
|
changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bullets over 60 words.
|
||||||
|
ci/changelog_gate.sh
|
||||||
|
|
||||||
.PHONY: smoke-bundle
|
.PHONY: smoke-bundle
|
||||||
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
||||||
@@ -128,15 +169,17 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
|||||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## flutter build for the current OS.
|
build: gen-build-info clide-cli ## flutter build for the current OS (incl. the C CLI client).
|
||||||
flutter build $(FLUTTER_OS)
|
flutter build $(FLUTTER_OS)
|
||||||
|
@install -m 755 $(CLIDE_CLI_BIN) $(CLI_BUNDLE_DEST)
|
||||||
|
@echo "==> bundled C client at $(CLI_BUNDLE_DEST)"
|
||||||
|
|
||||||
.PHONY: build-linux
|
.PHONY: build-linux
|
||||||
build-linux: ## flutter build linux (desktop bundle).
|
build-linux: gen-build-info ## flutter build linux (desktop bundle).
|
||||||
flutter build linux
|
flutter build linux
|
||||||
|
|
||||||
.PHONY: build-macos
|
.PHONY: build-macos
|
||||||
build-macos: ## flutter build macos (desktop bundle).
|
build-macos: gen-build-info ## flutter build macos (desktop bundle).
|
||||||
flutter build macos
|
flutter build macos
|
||||||
|
|
||||||
# -- install / uninstall -----------------------------------------------------
|
# -- install / uninstall -----------------------------------------------------
|
||||||
@@ -147,19 +190,23 @@ INSTALL_PREFIX ?= $(HOME)/.local/lib
|
|||||||
|
|
||||||
ifeq ($(FLUTTER_OS),linux)
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
BUNDLE_DIR := build/linux/x64/release/bundle
|
BUNDLE_DIR := build/linux/x64/release/bundle
|
||||||
|
# The C client ships beside the GUI runner so the in-app "Install clide
|
||||||
|
# command in PATH" affordance (T-212) can self-install from the bundle.
|
||||||
|
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli
|
||||||
else ifeq ($(FLUTTER_OS),macos)
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||||
|
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ICON_SIZES := 16 32 48 128 192 256 512
|
ICON_SIZES := 16 32 48 128 192 256 512
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build ## Build + install clide to ~/.local (INSTALL_PREFIX, INSTALL_DIR).
|
install: build clide-cli ## Build + install clide to ~/.local (INSTALL_PREFIX, INSTALL_DIR).
|
||||||
ifeq ($(FLUTTER_OS),linux)
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
@mkdir -p $(INSTALL_PREFIX) $(INSTALL_DIR)
|
@mkdir -p $(INSTALL_PREFIX) $(INSTALL_DIR)
|
||||||
rm -rf $(INSTALL_PREFIX)/clide
|
rm -rf $(INSTALL_PREFIX)/clide
|
||||||
cp -a $(BUNDLE_DIR) $(INSTALL_PREFIX)/clide
|
cp -a $(BUNDLE_DIR) $(INSTALL_PREFIX)/clide
|
||||||
ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide
|
install -m 755 $(CLIDE_CLI_BIN) $(INSTALL_DIR)/clide
|
||||||
@for size in $(ICON_SIZES); do \
|
@for size in $(ICON_SIZES); do \
|
||||||
dir=$(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps; \
|
dir=$(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps; \
|
||||||
mkdir -p $$dir; \
|
mkdir -p $$dir; \
|
||||||
@@ -167,17 +214,20 @@ ifeq ($(FLUTTER_OS),linux)
|
|||||||
done
|
done
|
||||||
@mkdir -p $(HOME)/.local/share/applications
|
@mkdir -p $(HOME)/.local/share/applications
|
||||||
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
|
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
|
||||||
> $(HOME)/.local/share/applications/clide.desktop
|
> $(HOME)/.local/share/applications/net.schweitz.clide.desktop
|
||||||
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||||
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||||
@echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide"
|
@echo "cli: $(INSTALL_DIR)/clide (C client)"
|
||||||
@echo "desktop: ~/.local/share/applications/clide.desktop"
|
@echo "gui: $(INSTALL_PREFIX)/clide/clide"
|
||||||
|
@echo "desktop: ~/.local/share/applications/net.schweitz.clide.desktop"
|
||||||
@echo "version: $(VERSION)"
|
@echo "version: $(VERSION)"
|
||||||
else ifeq ($(FLUTTER_OS),macos)
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
@mkdir -p $(HOME)/Applications
|
@mkdir -p $(HOME)/Applications $(INSTALL_DIR)
|
||||||
rm -rf $(HOME)/Applications/clide.app
|
rm -rf $(HOME)/Applications/clide.app
|
||||||
cp -a $(BUNDLE_DIR) $(HOME)/Applications/clide.app
|
cp -a $(BUNDLE_DIR) $(HOME)/Applications/clide.app
|
||||||
@echo "installed: ~/Applications/clide.app"
|
install -m 755 $(CLIDE_CLI_BIN) $(INSTALL_DIR)/clide
|
||||||
|
@echo "gui: ~/Applications/clide.app"
|
||||||
|
@echo "cli: $(INSTALL_DIR)/clide (C client)"
|
||||||
@echo "version: $(VERSION)"
|
@echo "version: $(VERSION)"
|
||||||
else
|
else
|
||||||
@echo "install not yet supported on $(FLUTTER_OS)"
|
@echo "install not yet supported on $(FLUTTER_OS)"
|
||||||
@@ -189,6 +239,7 @@ uninstall: ## Remove installed clide.
|
|||||||
ifeq ($(FLUTTER_OS),linux)
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
rm -f $(INSTALL_DIR)/clide
|
rm -f $(INSTALL_DIR)/clide
|
||||||
rm -rf $(INSTALL_PREFIX)/clide
|
rm -rf $(INSTALL_PREFIX)/clide
|
||||||
|
rm -f $(HOME)/.local/share/applications/net.schweitz.clide.desktop
|
||||||
rm -f $(HOME)/.local/share/applications/clide.desktop
|
rm -f $(HOME)/.local/share/applications/clide.desktop
|
||||||
@for size in $(ICON_SIZES); do \
|
@for size in $(ICON_SIZES); do \
|
||||||
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
|
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
|
||||||
@@ -197,6 +248,7 @@ ifeq ($(FLUTTER_OS),linux)
|
|||||||
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||||
@echo "uninstalled"
|
@echo "uninstalled"
|
||||||
else ifeq ($(FLUTTER_OS),macos)
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
|
rm -f $(INSTALL_DIR)/clide
|
||||||
rm -rf $(HOME)/Applications/clide.app
|
rm -rf $(HOME)/Applications/clide.app
|
||||||
@echo "uninstalled"
|
@echo "uninstalled"
|
||||||
endif
|
endif
|
||||||
@@ -233,49 +285,44 @@ dugite-fetch: ## Download and extract the dugite-native git distribution.
|
|||||||
dugite-clean: ## Remove the dugite-native directory.
|
dugite-clean: ## Remove the dugite-native directory.
|
||||||
rm -rf $(DUGITE_DIR)
|
rm -rf $(DUGITE_DIR)
|
||||||
|
|
||||||
# -- ptyc (C supporter tool) ---------------------------------------------
|
# -- clide-cli ----------------------------------------------------------
|
||||||
|
# The C `clide` shell client that talks to the in-process IPC server
|
||||||
|
# (T-99 / T-126). One source file, no third-party deps; the build
|
||||||
|
# target picks up whatever `cc` is on PATH.
|
||||||
|
|
||||||
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
CLIDE_CLI_SRC := native/clide-cli/clide.c
|
||||||
|
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
|
||||||
|
CC ?= cc
|
||||||
|
|
||||||
.PHONY: ptyc-build
|
.PHONY: clide-cli
|
||||||
ptyc-build: ## Build the ptyc PTY-spawn helper.
|
clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
|
||||||
$(MAKE) -C ptyc
|
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: ptyc-test
|
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
|
||||||
ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip).
|
@mkdir -p $(dir $(CLIDE_CLI_BIN))
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
|
||||||
$(MAKE) -C ptyc test
|
@echo "==> built $(CLIDE_CLI_BIN)"
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: ptyc-clean
|
.PHONY: clide-cli-clean
|
||||||
ptyc-clean: ## Clean ptyc build artefacts.
|
clide-cli-clean: ## Remove the compiled C `clide` client.
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
rm -f $(CLIDE_CLI_BIN)
|
||||||
$(MAKE) -C ptyc clean
|
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
# -- security -------------------------------------------------------------
|
# -- security -------------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: security
|
.PHONY: security
|
||||||
security: ## Dart advisory review + ptyc source review.
|
security: ## Dart advisory review.
|
||||||
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;"
|
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
|
||||||
@echo " ptyc is reviewed by reading it (tiny libc-only C)."
|
|
||||||
|
|
||||||
# -- pre-push gate --------------------------------------------------------
|
# -- pre-push gate --------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: decisions-validate
|
.PHONY: decisions-validate
|
||||||
decisions-validate: ## Parser dry-run over decisions/*.md.
|
decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejected}/*.md.
|
||||||
pql decisions validate
|
pql decisions validate
|
||||||
|
|
||||||
.PHONY: push-check
|
.PHONY: push-check
|
||||||
push-check: decisions-validate test-core test test-a11y ## Pre-push gate.
|
push-check: decisions-validate changelog-gate test-coverage coverage-gate test-core ## Pre-push gate (fast — <2 min target). Order is fail-fast: instant gates (decisions, changelog) first, then the coverage suite + gate (the expensive, most-likely-to-fail stage) BEFORE test-core — a coverage miss aborts here instead of after running everything, so a fix doesn't force a full re-run of the rest. test-coverage already runs the a11y suite (test/a11y), so no separate test-a11y pass.
|
||||||
|
|
||||||
|
.PHONY: push-check-full
|
||||||
|
push-check-full: push-check test-integration smoke-bundle ## Pre-release gate (push-check + integration + smoke; slower).
|
||||||
|
|
||||||
.PHONY: hooks
|
.PHONY: hooks
|
||||||
hooks: ## Install the repo's git hooks.
|
hooks: ## Install the repo's git hooks.
|
||||||
@@ -287,6 +334,5 @@ hooks: ## Install the repo's git hooks.
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Remove build artefacts.
|
clean: ## Remove build artefacts.
|
||||||
rm -rf build .dart_tool
|
rm -rf build .dart_tool
|
||||||
$(MAKE) ptyc-clean
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# clide
|
# clide
|
||||||
|
|
||||||
A Flutter desktop IDE for Claude Code. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence.
|
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence (D-41).
|
||||||
|
|
||||||
- **`lib/`** — all Dart code. Kernel services (theme, i18n, settings, panels, commands, focus), UI widgets, built-in extensions, and the extension contract.
|
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
||||||
- **`ptyc/`** — small C helper. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Every pane (shell, tmux, claude, LSP, debug adapter) goes through it.
|
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
||||||
|
- **`native/`** — vendored native libraries (`libtree-sitter.so` with wasmtime embedded). Linux only today.
|
||||||
- **[pql](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
- **[pql](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||||
|
|
||||||
Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI subcommand has a UI affordance and every UI action has a CLI equivalent.
|
Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI subcommand has a UI affordance and every UI action has a CLI equivalent (D-6).
|
||||||
|
|
||||||
## Built-in extensions
|
## Built-in extensions
|
||||||
|
|
||||||
@@ -18,10 +19,14 @@ canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, g
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires Flutter (stable channel) on the host. One-time setup:
|
Requires Flutter (stable channel) and [pql](https://github.com/postmeridiem/pql)
|
||||||
|
on the host. pql is a hard dependency — the pre-push gate runs `pql decisions
|
||||||
|
validate` and the governance + ticket workflow is built on it (see its repo for
|
||||||
|
install). One-time setup:
|
||||||
|
|
||||||
```
|
```
|
||||||
make hooks && flutter pub get
|
make hooks && flutter pub get
|
||||||
|
pql init # once, in the repo root — wires up the pql skill + perms
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
@@ -30,18 +35,24 @@ Then:
|
|||||||
make run # launch the desktop app
|
make run # launch the desktop app
|
||||||
make test # fast suite: analyze + format + unit + widget + golden
|
make test # fast suite: analyze + format + unit + widget + golden
|
||||||
make test-core # core subsystem tests (IPC, PTY, git, pane registry)
|
make test-core # core subsystem tests (IPC, PTY, git, pane registry)
|
||||||
|
make test-a11y # accessibility contract tests
|
||||||
make test-integration # real app boot integration tests
|
make test-integration # real app boot integration tests
|
||||||
make build-linux # flutter build linux
|
make build-linux # flutter build linux
|
||||||
make build-macos # flutter build macos
|
make build-macos # flutter build macos
|
||||||
make ptyc-build # build the ptyc PTY-spawn helper
|
make push-check # pre-push gate: decisions + core + fast + a11y + coverage + changelog
|
||||||
make push-check # pre-push gate: decisions + core + fast tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](legacy/).
|
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||||
|
|
||||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Architectural decisions: [`decisions/`](decisions/).
|
## Documentation
|
||||||
|
|
||||||
|
- [`docs/architecture.md`](docs/architecture.md) — current architecture (read this first).
|
||||||
|
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to clone, build, test, file tickets, and write D-records.
|
||||||
|
- [`docs/initial-plan.md`](docs/initial-plan.md) — historical design doc; preserved as a snapshot of the 2026-04 plan, much of it now superseded.
|
||||||
|
- [`governance/decisions/`](governance/decisions/) — confirmed decisions (`D-NNN`), open questions (`Q-NNN`), rejected alternatives (`R-NNN`).
|
||||||
|
- [`CLAUDE.md`](CLAUDE.md) — Claude-addressed working notes (guardrails, repo layout).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# clide tmux.conf — loaded via tmux -f for every Claude pane session.
|
||||||
|
# Tuned for embedding inside xterm.dart; no status bar, large
|
||||||
|
# scrollback, mouse-scroll passthrough, zero escape delay.
|
||||||
|
|
||||||
|
# No status bar — clide renders its own pane chrome.
|
||||||
|
set -g status off
|
||||||
|
|
||||||
|
# 50k lines of scrollback (tmux default is 2000).
|
||||||
|
set -g history-limit 50000
|
||||||
|
|
||||||
|
# Zero escape delay — xterm.dart delivers escape sequences
|
||||||
|
# atomically, so the 500ms default just adds latency.
|
||||||
|
set -sg escape-time 0
|
||||||
|
|
||||||
|
# Mouse on — scroll wheel events reach tmux's copy-mode so the
|
||||||
|
# user can scroll back through Claude output.
|
||||||
|
set -g mouse on
|
||||||
|
|
||||||
|
# 256color + true-color passthrough.
|
||||||
|
set -g default-terminal "xterm-256color"
|
||||||
|
set -ga terminal-overrides ",xterm-256color:Tc"
|
||||||
|
|
||||||
|
# Don't ring the bell visually or audibly — clide owns notifications.
|
||||||
|
set -g visual-bell off
|
||||||
|
set -g bell-action none
|
||||||
|
|
||||||
|
# Keep the session alive when the shell exits — clide manages
|
||||||
|
# lifecycle via pane.close, not tmux session destruction.
|
||||||
|
set -g remain-on-exit off
|
||||||
|
|
||||||
|
# Allow alt-screen passthrough for full-screen programs.
|
||||||
|
set -g alternate-screen on
|
||||||
|
|
||||||
|
# Focus events let the terminal's focus tracking work through tmux.
|
||||||
|
set -g focus-events on
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# clide default keymap.
|
||||||
|
#
|
||||||
|
# This is the baseline preset. Vim / VSCode / JetBrains presets are
|
||||||
|
# their own files (T-64/T-65/T-66) and replace bindings via the same
|
||||||
|
# YAML shape.
|
||||||
|
#
|
||||||
|
# Each binding:
|
||||||
|
# intent: <stable intent id> # see kernel/src/keymap/intents.dart
|
||||||
|
# keys: <chord> | [<chord>, ...] # `ctrl+shift+p`, `cmd+enter`, ...
|
||||||
|
# when: <when-clause> # optional; VS-Code-style boolean expr
|
||||||
|
#
|
||||||
|
# Identifiers in `when:` are scope flags published by producing
|
||||||
|
# services (e.g. `palette.open` when ClidePalette is mounted +
|
||||||
|
# visible). Missing flags evaluate to false.
|
||||||
|
|
||||||
|
name: default
|
||||||
|
|
||||||
|
bindings:
|
||||||
|
# -- Activation / focus -----------------------------------------------
|
||||||
|
# ActivateIntent has no when-clause: it dispatches via Actions.maybeInvoke
|
||||||
|
# against the focused context, so only widgets that opt in (ClideTappable's
|
||||||
|
# Actions wrapper) actually catch it. Text-input widgets handle Enter
|
||||||
|
# themselves first.
|
||||||
|
- intent: activate
|
||||||
|
keys: [enter, space]
|
||||||
|
- intent: dismiss
|
||||||
|
keys: escape
|
||||||
|
- intent: focus.next
|
||||||
|
keys: tab
|
||||||
|
- intent: focus.previous
|
||||||
|
keys: shift+tab
|
||||||
|
|
||||||
|
# Panel-to-panel cycling (sidebar → workspace → context).
|
||||||
|
# VS Code convention.
|
||||||
|
- intent: focus.nextPanel
|
||||||
|
keys: f6
|
||||||
|
- intent: focus.previousPanel
|
||||||
|
keys: shift+f6
|
||||||
|
|
||||||
|
# -- Command palette --------------------------------------------------
|
||||||
|
- intent: palette.open
|
||||||
|
keys: [ctrl+shift+p, meta+shift+p]
|
||||||
|
- intent: palette.selectNext
|
||||||
|
keys: [down, ctrl+n]
|
||||||
|
when: palette.open
|
||||||
|
- intent: palette.selectPrevious
|
||||||
|
keys: [up, ctrl+p]
|
||||||
|
when: palette.open
|
||||||
|
- intent: palette.accept
|
||||||
|
keys: enter
|
||||||
|
when: palette.open
|
||||||
|
- intent: dismiss
|
||||||
|
keys: escape
|
||||||
|
when: palette.open
|
||||||
|
|
||||||
|
# -- Quick open (fuzzy file finder) -----------------------------------
|
||||||
|
# ctrl+p / meta+p are free while the palette is closed; the palette
|
||||||
|
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
|
||||||
|
# is conflict-free. Arrow/enter/escape inside the overlay are handled
|
||||||
|
# locally by the widget.
|
||||||
|
- intent: quickOpen.open
|
||||||
|
keys: [ctrl+p, meta+p]
|
||||||
|
when: "!palette.open"
|
||||||
|
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
|
||||||
|
# would collide while the overlay is up).
|
||||||
|
- intent: quickOpen.selectNext
|
||||||
|
keys: [down, ctrl+n]
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: quickOpen.selectPrevious
|
||||||
|
keys: up
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: quickOpen.accept
|
||||||
|
keys: enter
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: dismiss
|
||||||
|
keys: escape
|
||||||
|
when: quickOpen.open
|
||||||
|
|
||||||
|
# -- Find in files ----------------------------------------------------
|
||||||
|
- intent: findInFiles.open
|
||||||
|
keys: [ctrl+shift+f, meta+shift+f]
|
||||||
|
|
||||||
|
# -- Text scale -------------------------------------------------------
|
||||||
|
# On most layouts `+` is `shift+equal`; we bind both so users who
|
||||||
|
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
|
||||||
|
- intent: text.scaleIncrease
|
||||||
|
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||||
|
- intent: text.scaleDecrease
|
||||||
|
keys: [ctrl+minus, meta+minus]
|
||||||
|
- intent: text.scaleReset
|
||||||
|
keys: [ctrl+0, meta+0]
|
||||||
|
|
||||||
|
# -- File (application menu, T-48) -------------------------------------
|
||||||
|
- intent: command:file.openFolder
|
||||||
|
keys: [ctrl+o, meta+o]
|
||||||
|
- intent: command:file.newWindow
|
||||||
|
keys: [ctrl+shift+n, meta+shift+n]
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# clide Vim keymap preset (T-65).
|
||||||
|
#
|
||||||
|
# Modal editing built on the keymap sequence layer (D-82). The Vim mode
|
||||||
|
# service (builtin.vim) publishes vim.normal / vim.insert / vim.visual
|
||||||
|
# scope flags; bindings here are guarded on them. Motions/edits resolve to
|
||||||
|
# `command:editor.vim.<action>`, which the editor applies to the buffer
|
||||||
|
# (read-only in command mode so keys never type); mode changes resolve to
|
||||||
|
# `command:vim.mode.<mode>`.
|
||||||
|
#
|
||||||
|
# Notation (D-82): `+` joins a chord, a space sequences (`d d`), a YAML
|
||||||
|
# list alternates (`[d, x]`). Repeat counts (`5j`) are captured by the
|
||||||
|
# matcher — no binding needed.
|
||||||
|
#
|
||||||
|
# Scope: the muscle-memory set, not bit-exact Vim. cw/$/^ are
|
||||||
|
# approximations; see vim_edit_ops.dart.
|
||||||
|
|
||||||
|
name: vim
|
||||||
|
|
||||||
|
bindings:
|
||||||
|
# ---- App shortcuts (shared with the default preset) -----------------
|
||||||
|
# Modified chords bubble past the editor to the global handler, so these
|
||||||
|
# keep working with the editor focused and under every mode.
|
||||||
|
- intent: palette.open
|
||||||
|
keys: [ctrl+shift+p, meta+shift+p]
|
||||||
|
- intent: palette.selectNext
|
||||||
|
keys: [down, ctrl+n]
|
||||||
|
when: palette.open
|
||||||
|
- intent: palette.selectPrevious
|
||||||
|
keys: [up, ctrl+p]
|
||||||
|
when: palette.open
|
||||||
|
- intent: palette.accept
|
||||||
|
keys: enter
|
||||||
|
when: palette.open
|
||||||
|
- intent: dismiss
|
||||||
|
keys: escape
|
||||||
|
when: palette.open
|
||||||
|
- intent: quickOpen.open
|
||||||
|
keys: [ctrl+p, meta+p]
|
||||||
|
when: "!palette.open"
|
||||||
|
- intent: quickOpen.selectNext
|
||||||
|
keys: [down, ctrl+n]
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: quickOpen.selectPrevious
|
||||||
|
keys: up
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: quickOpen.accept
|
||||||
|
keys: enter
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: dismiss
|
||||||
|
keys: escape
|
||||||
|
when: quickOpen.open
|
||||||
|
- intent: findInFiles.open
|
||||||
|
keys: [ctrl+shift+f, meta+shift+f]
|
||||||
|
- intent: focus.nextPanel
|
||||||
|
keys: f6
|
||||||
|
- intent: focus.previousPanel
|
||||||
|
keys: shift+f6
|
||||||
|
- intent: text.scaleIncrease
|
||||||
|
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||||
|
- intent: text.scaleDecrease
|
||||||
|
keys: [ctrl+minus, meta+minus]
|
||||||
|
- intent: text.scaleReset
|
||||||
|
keys: [ctrl+0, meta+0]
|
||||||
|
|
||||||
|
# ---- Mode transitions ------------------------------------------------
|
||||||
|
- intent: command:vim.mode.visual
|
||||||
|
keys: v
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:vim.mode.normal
|
||||||
|
keys: escape
|
||||||
|
when: "vim.insert || vim.visual"
|
||||||
|
|
||||||
|
# ---- Insert entry (normal → insert), positioned by the editor --------
|
||||||
|
- intent: command:editor.vim.insert
|
||||||
|
keys: i
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.append
|
||||||
|
keys: a
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.insertLineStart
|
||||||
|
keys: shift+i # I
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.appendLineEnd
|
||||||
|
keys: shift+a # A
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.openBelow
|
||||||
|
keys: o
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.openAbove
|
||||||
|
keys: shift+o # O
|
||||||
|
when: vim.normal
|
||||||
|
|
||||||
|
# ---- Motions (normal + visual) ---------------------------------------
|
||||||
|
- intent: command:editor.vim.left
|
||||||
|
keys: h
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.down
|
||||||
|
keys: j
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.up
|
||||||
|
keys: k
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.right
|
||||||
|
keys: l
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.wordForward
|
||||||
|
keys: w
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.wordBackward
|
||||||
|
keys: b
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.wordEnd
|
||||||
|
keys: e
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.lineStart
|
||||||
|
keys: "0"
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.lineEnd
|
||||||
|
keys: shift+4 # $
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.firstNonBlank
|
||||||
|
keys: shift+6 # ^
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.docStart
|
||||||
|
keys: "g g" # gg
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
- intent: command:editor.vim.docEnd
|
||||||
|
keys: shift+g # G
|
||||||
|
when: "vim.normal || vim.visual"
|
||||||
|
|
||||||
|
# ---- Normal-mode edits ----------------------------------------------
|
||||||
|
- intent: command:editor.vim.deleteChar
|
||||||
|
keys: x
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.deleteLine
|
||||||
|
keys: "d d"
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.deleteWord
|
||||||
|
keys: "d w"
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.deleteToEnd
|
||||||
|
keys: shift+d # D
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.changeLine
|
||||||
|
keys: "c c"
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.changeWord
|
||||||
|
keys: "c w"
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.yankLine
|
||||||
|
keys: "y y"
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.paste
|
||||||
|
keys: p
|
||||||
|
when: vim.normal
|
||||||
|
- intent: command:editor.vim.pasteBefore
|
||||||
|
keys: shift+p # P
|
||||||
|
when: vim.normal
|
||||||
|
|
||||||
|
# ---- Visual-mode operators ------------------------------------------
|
||||||
|
- intent: command:editor.vim.visualDelete
|
||||||
|
keys: [d, x]
|
||||||
|
when: vim.visual
|
||||||
|
- intent: command:editor.vim.visualYank
|
||||||
|
keys: y
|
||||||
|
when: vim.visual
|
||||||
|
- intent: command:editor.vim.visualChange
|
||||||
|
keys: c
|
||||||
|
when: vim.visual
|
||||||
@@ -36,7 +36,10 @@ schema_version: 1
|
|||||||
# About screen can read it at runtime.
|
# About screen can read it at runtime.
|
||||||
self:
|
self:
|
||||||
name: clide
|
name: clide
|
||||||
version: "2.0.0-dev"
|
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||||
|
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||||
|
# pubspec instead.
|
||||||
|
version: "2.1.0"
|
||||||
homepage: https://github.com/postmeridiem/clide
|
homepage: https://github.com/postmeridiem/clide
|
||||||
license: MIT
|
license: MIT
|
||||||
license_file: assets/LICENSE
|
license_file: assets/LICENSE
|
||||||
@@ -78,6 +81,17 @@ dependencies:
|
|||||||
Fill weights bundled.
|
Fill weights bundled.
|
||||||
weights_bundled: [Regular, Bold, Fill]
|
weights_bundled: [Regular, Bold, Fill]
|
||||||
|
|
||||||
|
- name: Catppuccin (palette)
|
||||||
|
kind: theme-palette
|
||||||
|
version: "mocha"
|
||||||
|
homepage: https://github.com/catppuccin/catppuccin
|
||||||
|
license: MIT
|
||||||
|
purpose: >-
|
||||||
|
Source palette for the bundled catppuccin-mocha theme and its
|
||||||
|
high-contrast sibling catppuccin-mocha-hc. Official hex values,
|
||||||
|
mapped to clide's SurfaceTokens (Mauve accent, Blue primary); the
|
||||||
|
faithful palette is not retuned (D-69).
|
||||||
|
|
||||||
- name: yaml
|
- name: yaml
|
||||||
kind: dart-package
|
kind: dart-package
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
@@ -87,17 +101,18 @@ dependencies:
|
|||||||
YAML parser for theme files and extension manifests. Justified
|
YAML parser for theme files and extension manifests. Justified
|
||||||
exception to prefer-zero-deps; Dart-team maintained.
|
exception to prefer-zero-deps; Dart-team maintained.
|
||||||
|
|
||||||
- name: xterm
|
- name: terminal (based on xterm.dart)
|
||||||
kind: dart-package
|
kind: inlined-source
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
homepage: https://pub.dev/packages/xterm
|
homepage: https://github.com/TerminalStudio/xterm.dart
|
||||||
license: MIT
|
license: MIT
|
||||||
|
license_file: lib/src/terminal/LICENSE
|
||||||
purpose: >-
|
purpose: >-
|
||||||
Flutter-native terminal emulator (ANSI / xterm / truecolor
|
Terminal emulator (ANSI / xterm / truecolor parser + renderer).
|
||||||
parser + renderer). Powers every pane that renders a PTY —
|
Inlined from xterm.dart v4.0.0 by xuty (MIT) and modified:
|
||||||
general terminal, Claude, diff views running shell commands.
|
Scrollable removed, quiver dependency replaced, zmodem/debugger
|
||||||
Writing a vt100 / ANSI parser is weeks of work for no fidelity
|
stripped, scroll forwarding rewritten. Original copyright and
|
||||||
gain.
|
MIT license preserved in lib/src/terminal/LICENSE.
|
||||||
|
|
||||||
- name: ffi
|
- name: ffi
|
||||||
kind: dart-package
|
kind: dart-package
|
||||||
@@ -215,21 +230,27 @@ dev_dependencies:
|
|||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
purpose: Flutter-team-recommended analyzer lint set (app/).
|
purpose: Flutter-team-recommended analyzer lint set (app/).
|
||||||
|
|
||||||
- name: lints
|
|
||||||
kind: dart-package
|
|
||||||
version: "5.0.0"
|
|
||||||
homepage: https://pub.dev/packages/lints
|
|
||||||
license: BSD-3-Clause
|
|
||||||
purpose: >-
|
|
||||||
Dart-team-recommended analyzer lint set for the Flutter-free
|
|
||||||
core package at the repo root.
|
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
kind: dart-package
|
kind: dart-package
|
||||||
version: "1.25.8"
|
version: "1.30.0"
|
||||||
homepage: https://pub.dev/packages/test
|
homepage: https://pub.dev/packages/test
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
purpose: >-
|
purpose: >-
|
||||||
Dart test runner for the core package (Flutter-free; the app
|
Dart test runner for the Flutter-free PTY tests under
|
||||||
uses flutter_test from the Flutter SDK for widget + golden
|
`dart test --tags pty` (flutter_test is used elsewhere).
|
||||||
coverage).
|
|
||||||
|
# Trademark / brand notices — third-party marks clide references but does
|
||||||
|
# NOT bundle. No artefact ships for these; tracked here for audit
|
||||||
|
# completeness and to record the nominative-use basis.
|
||||||
|
trademark_notices:
|
||||||
|
- mark: Claude name + accent colour (#d97757)
|
||||||
|
owner: Anthropic, PBC
|
||||||
|
reference: https://github.com/anthropics/skills/tree/main/skills/brand-guidelines
|
||||||
|
usage: >-
|
||||||
|
Nominative use only. "Claude" identifies the CLI that clide
|
||||||
|
integrates with; #d97757 is Anthropic's published Claude accent
|
||||||
|
colour, used solely to mark Claude's own messages/labels in the
|
||||||
|
conversation UI (not as clide's brand colour). clide is not
|
||||||
|
affiliated with, endorsed by, or partnered with Anthropic, bundles
|
||||||
|
no Anthropic logo or brand artwork, and ships its own logo. CLAUDE
|
||||||
|
is a registered trademark of Anthropic, PBC.
|
||||||
|
|||||||
@@ -1,436 +0,0 @@
|
|||||||
// clide — CLI + daemon entry point.
|
|
||||||
//
|
|
||||||
// One binary, two modes (per D-005):
|
|
||||||
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
|
|
||||||
// sends a request, prints the response, exits with the D-006
|
|
||||||
// exit code.
|
|
||||||
// * `clide --daemon` — long-running; owns the socket, dispatches
|
|
||||||
// requests. Subsystems (pane, files, editor, …) register handlers
|
|
||||||
// at boot.
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
|
||||||
import 'package:clide/kernel/src/toolchain.dart';
|
|
||||||
import 'package:clide/src/git/client.dart';
|
|
||||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
|
||||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
|
||||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
|
||||||
import 'package:clide/src/daemon/editor_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/files_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/git_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/pane_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/pql_commands.dart';
|
|
||||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
|
||||||
import 'package:clide/src/panes/registry.dart';
|
|
||||||
import 'package:clide/src/pql/client.dart';
|
|
||||||
|
|
||||||
Future<void> main(List<String> argv) async {
|
|
||||||
if (argv.isEmpty) {
|
|
||||||
_printHelp(stdout);
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (argv.first == '--daemon') {
|
|
||||||
await _runDaemon(argv.sublist(1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat
|
|
||||||
// positional argv into the structured IPC shape of the canonical
|
|
||||||
// editor.* / pane.* verb. Keeps Claude's tool-use pattern short.
|
|
||||||
final rest = argv.sublist(1);
|
|
||||||
switch (argv.first) {
|
|
||||||
case '--version':
|
|
||||||
case 'version':
|
|
||||||
await _runCliArgs('version', const {}, exitOnOk: true);
|
|
||||||
case '--help':
|
|
||||||
case '-h':
|
|
||||||
case 'help':
|
|
||||||
_printHelp(stdout);
|
|
||||||
exit(0);
|
|
||||||
case 'ping':
|
|
||||||
await _runCliArgs('ping', const {}, exitOnOk: true);
|
|
||||||
case 'open':
|
|
||||||
if (rest.isEmpty) _die('usage: clide open <path>');
|
|
||||||
await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true);
|
|
||||||
case 'active':
|
|
||||||
await _runCliArgs('editor.active', const {}, exitOnOk: true);
|
|
||||||
case 'insert':
|
|
||||||
final text = await _readTextArg(rest);
|
|
||||||
await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true);
|
|
||||||
case 'replace-selection':
|
|
||||||
final text = await _readTextArg(rest);
|
|
||||||
await _runCliArgs(
|
|
||||||
'editor.replace-selection',
|
|
||||||
{'text': text},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'save':
|
|
||||||
await _runCliArgs('editor.save', const {}, exitOnOk: true);
|
|
||||||
case 'git':
|
|
||||||
await _runGit(rest);
|
|
||||||
case 'tail':
|
|
||||||
await _runTail(rest);
|
|
||||||
default:
|
|
||||||
// Unknown-to-the-CLI commands still go over IPC — the daemon is
|
|
||||||
// authoritative about what's registered. Lets extensions add
|
|
||||||
// subcommands without the CLI caring. Args forward as-is under
|
|
||||||
// {argv: [...]} so daemon-side can parse whatever shape it wants.
|
|
||||||
await _runCliArgs(
|
|
||||||
argv.first,
|
|
||||||
{'argv': rest},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _printHelp(IOSink sink) {
|
|
||||||
sink.writeln('''
|
|
||||||
clide $clideVersion — Flutter desktop IDE for Claude Code.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clide --daemon Run the long-running daemon process.
|
|
||||||
clide <subcommand> Run a one-shot subcommand against the daemon.
|
|
||||||
|
|
||||||
Built-in subcommands:
|
|
||||||
ping Round-trip a ping to the daemon.
|
|
||||||
version Print the clide version.
|
|
||||||
help Print this help.
|
|
||||||
|
|
||||||
Editor (tier 2):
|
|
||||||
open <path> Open a file in the editor (editor.open).
|
|
||||||
active Print the active buffer (editor.active).
|
|
||||||
insert <text|-> Insert text at the cursor in the active buffer.
|
|
||||||
`-` reads text from stdin.
|
|
||||||
replace-selection <…> Replace the selected text in the active buffer.
|
|
||||||
`-` reads text from stdin.
|
|
||||||
save Save the active buffer (editor.save).
|
|
||||||
|
|
||||||
Git (tier 3):
|
|
||||||
git status Working tree status (staged/unstaged/conflicts).
|
|
||||||
git diff [--staged] [P] Diff for file(s) P, or all if omitted.
|
|
||||||
git stage <paths…> Stage files (git add).
|
|
||||||
git stage-all Stage everything (git add -A).
|
|
||||||
git unstage [paths…] Unstage files (git reset HEAD).
|
|
||||||
git discard <paths…> Discard unstaged changes in files.
|
|
||||||
git commit "<msg>" Commit staged changes.
|
|
||||||
git log [--count N] Recent commit log (default 20).
|
|
||||||
git stash [--message M] Stash working changes.
|
|
||||||
git stash-pop Pop the top stash entry.
|
|
||||||
git pull Pull from remote.
|
|
||||||
git push [remote] [br] Push to remote.
|
|
||||||
|
|
||||||
Event subscription:
|
|
||||||
tail --events [--filter SUBSYSTEM[:ID]]
|
|
||||||
Stream events as JSON lines. --filter keeps
|
|
||||||
only events from one subsystem, optionally
|
|
||||||
narrowed to a single id. Exits on SIGINT.
|
|
||||||
|
|
||||||
Any other subcommand is forwarded to the daemon; registered handlers
|
|
||||||
(e.g. `clide git status` once `builtin.git` lands) resolve there.
|
|
||||||
Matches D-006's exit-code contract:
|
|
||||||
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runDaemon(List<String> args) async {
|
|
||||||
final socketPath = defaultSocketPath();
|
|
||||||
final dispatcher = DaemonDispatcher();
|
|
||||||
late final DaemonServer server;
|
|
||||||
server = DaemonServer(
|
|
||||||
socketPath: socketPath,
|
|
||||||
dispatch: dispatcher.dispatch,
|
|
||||||
);
|
|
||||||
final toolchain = Toolchain();
|
|
||||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
|
||||||
|
|
||||||
final events = _ServerEventSink(server);
|
|
||||||
final registry = PaneRegistry(events: events);
|
|
||||||
registerPaneCommands(dispatcher, registry);
|
|
||||||
|
|
||||||
final files = FilesService.atCwd(events: events);
|
|
||||||
registerFilesCommands(dispatcher, files);
|
|
||||||
|
|
||||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
|
||||||
registerEditorCommands(dispatcher, editor);
|
|
||||||
final gitClient = GitClient(toolchain: toolchain, workDir: files.root);
|
|
||||||
registerGitCommands(dispatcher, gitClient, events);
|
|
||||||
|
|
||||||
final pql = PqlClient(workDir: files.root, toolchain: toolchain);
|
|
||||||
registerPqlCommands(dispatcher, pql);
|
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
|
||||||
void shutdown(ProcessSignal sig) {
|
|
||||||
if (!stopping.isCompleted) {
|
|
||||||
stderr.writeln('clide daemon: received ${sig.toString()}, shutting down');
|
|
||||||
stopping.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessSignal.sigint.watch().listen(shutdown);
|
|
||||||
ProcessSignal.sigterm.watch().listen(shutdown);
|
|
||||||
|
|
||||||
await server.start();
|
|
||||||
await stopping.future;
|
|
||||||
await registry.shutdown();
|
|
||||||
await editor.shutdown();
|
|
||||||
await files.shutdown();
|
|
||||||
await server.stop();
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thin adapter: the server doesn't `implement DaemonEventSink` itself
|
|
||||||
/// (that would tie ipc/ to panes/); instead the daemon entrypoint wraps
|
|
||||||
/// it at the seam where both are known.
|
|
||||||
class _ServerEventSink implements DaemonEventSink {
|
|
||||||
_ServerEventSink(this._server);
|
|
||||||
final DaemonServer _server;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void emit(IpcEvent event) => _server.broadcast(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CLI helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Read the "text" argument for insert / replace-selection. A lone
|
|
||||||
/// `-` means "slurp stdin"; anything else is concatenated into the
|
|
||||||
/// text body (so `clide insert hello world` emits "hello world").
|
|
||||||
Future<String> _readTextArg(List<String> rest) async {
|
|
||||||
if (rest.isEmpty) _die('usage: clide <verb> <text> (or `-` to read stdin)');
|
|
||||||
if (rest.length == 1 && rest.first == '-') {
|
|
||||||
final bytes = <int>[];
|
|
||||||
await for (final chunk in stdin) {
|
|
||||||
bytes.addAll(chunk);
|
|
||||||
}
|
|
||||||
return utf8.decode(bytes);
|
|
||||||
}
|
|
||||||
return rest.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Socket> _connectSocket() async {
|
|
||||||
final socketPath = defaultSocketPath();
|
|
||||||
try {
|
|
||||||
return await Socket.connect(
|
|
||||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'daemon not reachable at $socketPath',
|
|
||||||
hint: 'run `clide --daemon` in another terminal.',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runCliArgs(
|
|
||||||
String cmd,
|
|
||||||
Map<String, Object?> args, {
|
|
||||||
required bool exitOnOk,
|
|
||||||
}) async {
|
|
||||||
final socket = await _connectSocket();
|
|
||||||
final request = IpcRequest(id: '1', cmd: cmd, args: args);
|
|
||||||
socket.writeln(request.encode());
|
|
||||||
|
|
||||||
// Responses come back on the same socket. Events may be interleaved
|
|
||||||
// (the daemon broadcasts), so we skip events until we see the
|
|
||||||
// response whose id matches our request.
|
|
||||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await for (final line in lines) {
|
|
||||||
if (line.isEmpty) continue;
|
|
||||||
final msg = IpcMessage.decode(line);
|
|
||||||
if (msg is! IpcResponse) continue;
|
|
||||||
if (msg.id != request.id) continue;
|
|
||||||
await socket.close();
|
|
||||||
if (msg.ok) {
|
|
||||||
stdout.writeln(jsonEncode(msg.data));
|
|
||||||
if (exitOnOk) exit(IpcExitCode.ok);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
final err = msg.error!;
|
|
||||||
_emitError(
|
|
||||||
code: err.code,
|
|
||||||
kind: err.kind,
|
|
||||||
message: err.message,
|
|
||||||
hint: err.hint,
|
|
||||||
);
|
|
||||||
exit(err.code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'daemon closed socket before responding',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
} on FormatException catch (e) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'bad response from daemon: $e',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `clide git <verb> [args]` — maps to `git.*` IPC verbs.
|
|
||||||
Future<void> _runGit(List<String> args) async {
|
|
||||||
if (args.isEmpty) {
|
|
||||||
_die('usage: clide git <verb> [args…]');
|
|
||||||
}
|
|
||||||
switch (args.first) {
|
|
||||||
case 'status':
|
|
||||||
await _runCliArgs('git.status', const {}, exitOnOk: true);
|
|
||||||
case 'diff':
|
|
||||||
final staged = args.contains('--staged');
|
|
||||||
final paths = args.sublist(1).where((a) => !a.startsWith('--')).toList();
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.diff',
|
|
||||||
{'staged': staged, if (paths.isNotEmpty) 'paths': paths},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'stage':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
if (paths.isEmpty) _die('usage: clide git stage <path…>');
|
|
||||||
await _runCliArgs('git.stage', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'stage-all':
|
|
||||||
await _runCliArgs('git.stage-all', const {}, exitOnOk: true);
|
|
||||||
case 'unstage':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
await _runCliArgs('git.unstage', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'discard':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
if (paths.isEmpty) _die('usage: clide git discard <path…>');
|
|
||||||
await _runCliArgs('git.discard', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'commit':
|
|
||||||
if (args.length < 2) _die('usage: clide git commit "<message>"');
|
|
||||||
final message = args.sublist(1).join(' ');
|
|
||||||
await _runCliArgs('git.commit', {'message': message}, exitOnOk: true);
|
|
||||||
case 'log':
|
|
||||||
var count = 20;
|
|
||||||
for (var i = 1; i < args.length; i++) {
|
|
||||||
if (args[i] == '--count' && i + 1 < args.length) {
|
|
||||||
count = int.tryParse(args[i + 1]) ?? 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _runCliArgs('git.log', {'count': count}, exitOnOk: true);
|
|
||||||
case 'stash':
|
|
||||||
String? message;
|
|
||||||
for (var i = 1; i < args.length; i++) {
|
|
||||||
if (args[i] == '--message' && i + 1 < args.length) {
|
|
||||||
message = args[i + 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.stash',
|
|
||||||
{if (message != null) 'message': message},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'stash-pop':
|
|
||||||
await _runCliArgs('git.stash-pop', const {}, exitOnOk: true);
|
|
||||||
case 'pull':
|
|
||||||
await _runCliArgs('git.pull', const {}, exitOnOk: true);
|
|
||||||
case 'push':
|
|
||||||
final rest = args.sublist(1);
|
|
||||||
final setUpstream = rest.contains('-u');
|
|
||||||
final positional = rest.where((a) => a != '-u').toList();
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.push',
|
|
||||||
{
|
|
||||||
if (setUpstream) 'setUpstream': true,
|
|
||||||
if (positional.isNotEmpty) 'remote': positional[0],
|
|
||||||
if (positional.length > 1) 'branch': positional[1],
|
|
||||||
},
|
|
||||||
exitOnOk: true);
|
|
||||||
default:
|
|
||||||
_die('unknown git verb: ${args.first}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events.
|
|
||||||
Future<void> _runTail(List<String> args) async {
|
|
||||||
// Parse flags: --events (required today; keeps us honest when more
|
|
||||||
// modes like --history land), --filter SUBSYSTEM[:ID].
|
|
||||||
var wantEvents = false;
|
|
||||||
String? filterSubsystem;
|
|
||||||
String? filterId;
|
|
||||||
for (var i = 0; i < args.length; i++) {
|
|
||||||
final a = args[i];
|
|
||||||
if (a == '--events') {
|
|
||||||
wantEvents = true;
|
|
||||||
} else if (a == '--filter') {
|
|
||||||
if (i + 1 >= args.length) _die('--filter requires an argument');
|
|
||||||
final spec = args[++i];
|
|
||||||
final colon = spec.indexOf(':');
|
|
||||||
if (colon < 0) {
|
|
||||||
filterSubsystem = spec;
|
|
||||||
} else {
|
|
||||||
filterSubsystem = spec.substring(0, colon);
|
|
||||||
filterId = spec.substring(colon + 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_die('unknown argument: $a');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!wantEvents) _die('clide tail: pass --events');
|
|
||||||
|
|
||||||
final socket = await _connectSocket();
|
|
||||||
|
|
||||||
// Shutdown on SIGINT / SIGTERM — close the socket so the stream
|
|
||||||
// drains and we exit cleanly.
|
|
||||||
void quit() {
|
|
||||||
unawaited(socket.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessSignal.sigint.watch().listen((_) => quit());
|
|
||||||
ProcessSignal.sigterm.watch().listen((_) => quit());
|
|
||||||
|
|
||||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await for (final line in lines) {
|
|
||||||
if (line.isEmpty) continue;
|
|
||||||
IpcMessage msg;
|
|
||||||
try {
|
|
||||||
msg = IpcMessage.decode(line);
|
|
||||||
} on FormatException {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (msg is! IpcEvent) continue;
|
|
||||||
if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue;
|
|
||||||
if (filterId != null && msg.data['id'] != filterId) continue;
|
|
||||||
stdout.writeln(line);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await socket.close();
|
|
||||||
}
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitError({
|
|
||||||
required int code,
|
|
||||||
required String kind,
|
|
||||||
required String message,
|
|
||||||
String? hint,
|
|
||||||
}) {
|
|
||||||
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
|
|
||||||
stderr.writeln(jsonEncode(err.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Never _die(String msg) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.userError,
|
|
||||||
kind: IpcErrorKind.userError,
|
|
||||||
message: msg,
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.userError);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CHANGELOG concision gate — enforces a single 60-word per-bullet hard
|
||||||
|
# cap on the `## [Unreleased]` section. Released sections are frozen
|
||||||
|
# and skipped (don't penalize historical entries pre-dating the rule).
|
||||||
|
#
|
||||||
|
# A "bullet" is a markdown list item beginning with `- `, including any
|
||||||
|
# indented continuation lines until the next bullet, blank line, or
|
||||||
|
# heading. Word count is whitespace-tokenized.
|
||||||
|
#
|
||||||
|
# A soft 40-word warning was tried earlier and dropped — warnings that
|
||||||
|
# never block a push just normalise drift, so the gate is now binary.
|
||||||
|
#
|
||||||
|
# Bypass: never. If the rule rejects something genuinely user-visible
|
||||||
|
# that needs more context, the context belongs in the commit body or a
|
||||||
|
# D-record — see .claude/skills/git-commit/SKILL.md.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
CHANGELOG=CHANGELOG.md
|
||||||
|
HARD_CAP=60
|
||||||
|
|
||||||
|
if [[ ! -f "$CHANGELOG" ]]; then
|
||||||
|
echo "==> changelog gate: $CHANGELOG missing" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk -v hard="$HARD_CAP" '
|
||||||
|
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0 }
|
||||||
|
|
||||||
|
function check_bullet() {
|
||||||
|
if (bullet == "") return
|
||||||
|
# Trim the leading "- " marker, then tokenize on whitespace.
|
||||||
|
gsub(/^- +/, "", bullet)
|
||||||
|
n = split(bullet, _words, /[[:space:]]+/)
|
||||||
|
if (n > hard) {
|
||||||
|
printf "FAIL line %d: bullet is %d words (cap %d)\n", bullet_start, n, hard
|
||||||
|
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
|
||||||
|
fail++
|
||||||
|
}
|
||||||
|
bullet = ""
|
||||||
|
bullet_start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/^## \[Unreleased\]/ { in_unreleased = 1; next }
|
||||||
|
/^## \[/ && in_unreleased { check_bullet(); in_unreleased = 0; exit_loop = 1 }
|
||||||
|
!in_unreleased { next }
|
||||||
|
|
||||||
|
# Within Unreleased.
|
||||||
|
/^### / { check_bullet(); next } # subsection header
|
||||||
|
/^[[:space:]]*$/ { check_bullet(); next } # blank line ends bullet
|
||||||
|
/^- / { # new bullet
|
||||||
|
check_bullet()
|
||||||
|
bullet = $0
|
||||||
|
bullet_start = NR
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+/ && bullet != "" { # continuation of current bullet
|
||||||
|
bullet = bullet " " $0
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
check_bullet()
|
||||||
|
if (fail > 0) {
|
||||||
|
printf "\n==> changelog gate FAIL: %d bullet(s) over %d words.\n", fail, hard
|
||||||
|
printf " Trim them. Rationale, probe results, behavior-change deep dives\n"
|
||||||
|
printf " belong in the commit body, a D-record, or the ticket — not here.\n"
|
||||||
|
printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
printf "==> changelog gate OK\n"
|
||||||
|
}
|
||||||
|
' "$CHANGELOG"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Coverage gate — fails if total line coverage drops below the
|
||||||
|
# `coverage_floor:` value in pubspec.yaml. Driven by D-66.
|
||||||
|
#
|
||||||
|
# Reads coverage/lcov.info (generated by `flutter test --coverage`,
|
||||||
|
# which `ci/test.sh` runs as part of the fast suite). Parses the
|
||||||
|
# total LF/LH counts and compares the integer percentage against
|
||||||
|
# the floor. The floor only ratchets up — bumping it requires an
|
||||||
|
# explicit edit to pubspec.yaml committed alongside the test
|
||||||
|
# additions that earned the bump.
|
||||||
|
#
|
||||||
|
# Self-contained parser (awk) — does not depend on `lcov` being
|
||||||
|
# installed on the dev machine.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
LCOV=coverage/lcov.info
|
||||||
|
|
||||||
|
if [[ ! -f "$LCOV" ]]; then
|
||||||
|
echo "==> coverage gate: $LCOV missing — run \`make test\` first (it writes lcov)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
floor=$(awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2; exit}' pubspec.yaml)
|
||||||
|
if [[ -z "$floor" ]]; then
|
||||||
|
echo "==> coverage gate: pubspec.yaml is missing coverage_floor: — see D-66" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
read measured measured_int < <(
|
||||||
|
awk -F: '
|
||||||
|
/^LF:/ { lf += $2 }
|
||||||
|
/^LH:/ { lh += $2 }
|
||||||
|
END {
|
||||||
|
pct = (lh / lf) * 100
|
||||||
|
printf "%.2f %d\n", pct, int(pct)
|
||||||
|
}
|
||||||
|
' "$LCOV"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (( measured_int < floor )); then
|
||||||
|
echo "==> coverage gate FAIL: ${measured}% < floor ${floor}%"
|
||||||
|
echo " Add tests, or — if the drop is intentional — explain in the commit and lower the floor explicitly."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( measured_int > floor )); then
|
||||||
|
echo "==> coverage gate OK: ${measured}% (floor ${floor}%) — ${measured_int}% available; consider bumping pubspec.yaml coverage_floor: to ${measured_int}"
|
||||||
|
else
|
||||||
|
echo "==> coverage gate OK: ${measured}% (floor ${floor}%)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merge lcov files into one, on stdout.
|
||||||
|
|
||||||
|
clide runs coverage in two passes — the parallel pool and a serial
|
||||||
|
(`--tags serial --concurrency=1`) pass for tests that can't share the
|
||||||
|
parallel runner (T-193). Each `flutter test --coverage` pass overwrites
|
||||||
|
coverage/lcov.info, and the coverage gate naively sums LF:/LH: across
|
||||||
|
records, so a source file appearing in BOTH passes would double-count.
|
||||||
|
|
||||||
|
This unions DA (line→hits) per source file, taking the MAX hit count (a
|
||||||
|
line executed in EITHER pass counts as hit), then recomputes LF/LH. Line
|
||||||
|
coverage only — which is all flutter emits and all the gate reads. No
|
||||||
|
`lcov` dependency.
|
||||||
|
|
||||||
|
Usage: merge_lcov.py a.info b.info [...] > merged.info
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
files = {} # source path -> {line: hits}
|
||||||
|
order = [] # first-seen order, for stable output
|
||||||
|
|
||||||
|
for path in sys.argv[1:]:
|
||||||
|
cur = None
|
||||||
|
with open(path) as fh:
|
||||||
|
for raw in fh:
|
||||||
|
line = raw.rstrip("\n")
|
||||||
|
if line.startswith("SF:"):
|
||||||
|
cur = line[3:]
|
||||||
|
if cur not in files:
|
||||||
|
files[cur] = {}
|
||||||
|
order.append(cur)
|
||||||
|
elif line.startswith("DA:") and cur is not None:
|
||||||
|
num, _, hits = line[3:].partition(",")
|
||||||
|
num, hits = int(num), int(hits)
|
||||||
|
files[cur][num] = max(files[cur].get(num, 0), hits)
|
||||||
|
elif line == "end_of_record":
|
||||||
|
cur = None
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for sf in order:
|
||||||
|
da = files[sf]
|
||||||
|
out.append("SF:" + sf)
|
||||||
|
for num in sorted(da):
|
||||||
|
out.append(f"DA:{num},{da[num]}")
|
||||||
|
out.append(f"LF:{len(da)}")
|
||||||
|
out.append(f"LH:{sum(1 for h in da.values() if h > 0)}")
|
||||||
|
out.append("end_of_record")
|
||||||
|
|
||||||
|
sys.stdout.write("\n".join(out) + "\n")
|
||||||
@@ -1,18 +1,58 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Fast test layer — analyze + format + unit + widget + golden.
|
# Fast test layer — analyze + format + unit + widget + golden.
|
||||||
# Runs in <60s on a warm cache. Called from `make test` and the
|
#
|
||||||
# pre-push hook.
|
# Two modes (T-192):
|
||||||
|
# ci/test.sh dev inner loop — NO coverage, parallel. ~20s warm.
|
||||||
|
# ci/test.sh --coverage gate run — instrumented; writes coverage/lcov.info
|
||||||
|
# for coverage-gate. ~36s (coverage is the floor and
|
||||||
|
# is concurrency-insensitive, measured — so no
|
||||||
|
# --concurrency here; it buys nothing).
|
||||||
|
#
|
||||||
|
# Both pass `--timeout 60s` so a hung test (a stray pumpAndSettle, or a
|
||||||
|
# real-time deadlock) fails fast instead of wedging the runner for ~10 min and
|
||||||
|
# stalling the pre-push gate. (Use the pumpAsync helper in tests; never
|
||||||
|
# pumpAndSettle / Future.delayed(Duration.zero) inside testWidgets.)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Reporter: failures-only keeps the gate output to failing tests + a final
|
||||||
|
# pass/fail count, instead of one line per test (the `expanded` reporter the
|
||||||
|
# runner picks when stdout isn't a TTY — which buries real failures in
|
||||||
|
# thousands of pass lines). Override with TEST_REPORTER=expanded when
|
||||||
|
# debugging a specific run. (T-242)
|
||||||
|
REPORTER="${TEST_REPORTER:-failures-only}"
|
||||||
|
|
||||||
|
coverage=0
|
||||||
|
[[ "${1:-}" == "--coverage" ]] && coverage=1
|
||||||
|
|
||||||
echo "==> flutter analyze"
|
echo "==> flutter analyze"
|
||||||
flutter analyze --no-fatal-infos
|
flutter analyze
|
||||||
|
|
||||||
echo "==> dart format (whole tree)"
|
echo "==> dart format (whole tree)"
|
||||||
dart format --set-exit-if-changed .
|
dart format --set-exit-if-changed .
|
||||||
|
|
||||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
echo "==> dart test (pty — unreliable under the flutter test runner; serial)"
|
||||||
dart test --tags forkpty test/pty/session_test.dart
|
# --concurrency=1: these spawn real PTYs and compete for fds when run in
|
||||||
|
# parallel, which flaked them (registry/session). Serialize — the proper fix
|
||||||
|
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
|
||||||
|
dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart
|
||||||
|
|
||||||
echo "==> flutter test (unit + widget + golden)"
|
# The parallel pool excludes both pty (runs under dart test, above) and
|
||||||
flutter test --exclude-tags forkpty
|
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
|
||||||
|
# pass below). See dart_test.yaml + T-193.
|
||||||
|
if [[ "$coverage" == 1 ]]; then
|
||||||
|
echo "==> flutter test --coverage (parallel pool; excludes pty + serial)"
|
||||||
|
flutter test -r "$REPORTER" --coverage --exclude-tags "pty || serial" --timeout 60s
|
||||||
|
cp coverage/lcov.info coverage/lcov.parallel.info
|
||||||
|
echo "==> flutter test --coverage (serial-tagged; --concurrency=1)"
|
||||||
|
flutter test -r "$REPORTER" --coverage --tags serial --concurrency=1 --timeout 60s
|
||||||
|
echo "==> merge coverage (parallel + serial passes → coverage/lcov.info)"
|
||||||
|
python3 ci/merge_lcov.py coverage/lcov.parallel.info coverage/lcov.info > coverage/lcov.merged.info
|
||||||
|
mv coverage/lcov.merged.info coverage/lcov.info
|
||||||
|
rm -f coverage/lcov.parallel.info
|
||||||
|
else
|
||||||
|
echo "==> flutter test (dev; parallel pool, excludes pty + serial)"
|
||||||
|
flutter test -r "$REPORTER" --exclude-tags "pty || serial" --concurrency=12 --timeout 60s
|
||||||
|
echo "==> flutter test (dev; serial-tagged, --concurrency=1)"
|
||||||
|
flutter test -r "$REPORTER" --tags serial --concurrency=1 --timeout 60s
|
||||||
|
fi
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> a11y suite"
|
echo "==> a11y suite"
|
||||||
flutter test test/a11y/
|
# failures-only: failing tests + a final count, not one line per test.
|
||||||
|
# Override with TEST_REPORTER=expanded when debugging. (T-242)
|
||||||
|
flutter test -r "${TEST_REPORTER:-failures-only}" test/a11y/
|
||||||
|
|||||||
@@ -20,32 +20,48 @@ if ! command -v dart >/dev/null; then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v ptyc >/dev/null && [[ ! -x "ptyc/bin/ptyc" ]]; then
|
|
||||||
echo "test-core: building ptyc (required by PTY tests)"
|
|
||||||
make -C ptyc >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
||||||
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
||||||
# hang.
|
# hang.
|
||||||
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
||||||
|
|
||||||
|
# failures-only: print failing tests + a final count, not one line per test.
|
||||||
|
# Override with TEST_REPORTER=expanded when debugging. (T-242)
|
||||||
|
REPORTER="${TEST_REPORTER:-failures-only}"
|
||||||
|
|
||||||
# Run dart test in its own process group so we can kill descendants on
|
# Run dart test in its own process group so we can kill descendants on
|
||||||
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
||||||
# after SIGTERM if the test ignores it.
|
# after SIGTERM if the test ignores it.
|
||||||
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql"
|
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql"
|
||||||
|
|
||||||
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
|
# Run a `dart test` pass under the hard timeout + process-group kill.
|
||||||
|
run_pass() {
|
||||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
||||||
setsid --wait dart test $CORE_DIRS ; then
|
setsid --wait dart test -r "$REPORTER" "$@" ; then
|
||||||
rc=$?
|
rc=$?
|
||||||
if [[ $rc -eq 124 ]]; then
|
if [[ $rc -eq 124 ]]; then
|
||||||
echo "test-core: TIMEOUT — killing descendants" >&2
|
echo "test-core: TIMEOUT — killing descendants" >&2
|
||||||
pkill -9 -f "dart test test/" 2>/dev/null || true
|
pkill -9 -f "dart test" 2>/dev/null || true
|
||||||
pkill -9 -f "ptyc" 2>/dev/null || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
exit $rc
|
exit $rc
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Some core tests must not share the parallel pool:
|
||||||
|
# - pty: spawn real PTYs (posix_openpt + posix_spawn) + a reader isolate;
|
||||||
|
# in the parallel pool they contend for fds + CPU and the isolate starves,
|
||||||
|
# flaking 'write sends keystrokes to child' (T-292).
|
||||||
|
# - serial: spawn real `pql` processes against the shared on-disk
|
||||||
|
# `.pql/pql.db`; concurrent invocations contend for the SQLite lock and
|
||||||
|
# flake with PqlException(69) (db busy). (T-193, surfaced by the pql 1.10
|
||||||
|
# record_id migration.)
|
||||||
|
# Run both in one --concurrency=1 pass (matching ci/test.sh's serial handling),
|
||||||
|
# then everything else in parallel.
|
||||||
|
echo "test-core: dart test (pty + serial; --concurrency=1) (timeout ${TIMEOUT_SECONDS}s)"
|
||||||
|
run_pass --concurrency=1 --tags "pty || serial" $CORE_DIRS
|
||||||
|
|
||||||
|
echo "test-core: dart test (rest; parallel, excludes pty + serial) (timeout ${TIMEOUT_SECONDS}s)"
|
||||||
|
run_pass --exclude-tags "pty || serial" $CORE_DIRS
|
||||||
|
|
||||||
echo "test-core: ok"
|
echo "test-core: ok"
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Generate + summarize lcov coverage. No thresholds yet (see plan's
|
|
||||||
# "Open questions deferred" — we let the suite run for a week of real
|
|
||||||
# commits before setting hard gates that would just need tuning).
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
echo "==> flutter test --coverage"
|
|
||||||
flutter test --coverage
|
|
||||||
|
|
||||||
if command -v lcov >/dev/null 2>&1; then
|
|
||||||
echo "==> lcov summary"
|
|
||||||
lcov --summary coverage/lcov.info
|
|
||||||
fi
|
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# End-to-end layer: daemon subprocess + web WASM Playwright smoke.
|
# End-to-end layer: web WASM Playwright smoke.
|
||||||
# Neither fits in `make test`; together they're the "everything still
|
|
||||||
# works across process/runtime boundaries" gate.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> build bin/clide (required by daemon subprocess test)"
|
|
||||||
make build
|
|
||||||
|
|
||||||
echo "==> daemon subprocess test"
|
|
||||||
dart test test/daemon/
|
|
||||||
|
|
||||||
echo "==> browser WASM smoke (Playwright)"
|
echo "==> browser WASM smoke (Playwright)"
|
||||||
./tools/ui/build.sh
|
./tools/ui/build.sh
|
||||||
./tools/ui/serve.sh
|
./tools/ui/serve.sh
|
||||||
|
|||||||
@@ -0,0 +1,776 @@
|
|||||||
|
# clide — External Consultant Review
|
||||||
|
|
||||||
|
**Date:** 2026-05-14
|
||||||
|
**Scope:** Full-repository assessment of clide at `main` (commit `9030e56`).
|
||||||
|
**Method:** Six independent specialist reviewers, each given read-only access and a
|
||||||
|
brief covering best practice, clean code, architecture, usability, stability,
|
||||||
|
expandability, style, consistency, and general quality. Reviewers did not see each
|
||||||
|
other's findings; cross-cutting themes below are genuine independent agreement.
|
||||||
|
|
||||||
|
**Panel:**
|
||||||
|
| Lens | Reviewer |
|
||||||
|
|---|---|
|
||||||
|
| Architecture | Software Architect |
|
||||||
|
| Tests & quality gates | Test / QA Analyst |
|
||||||
|
| UX & accessibility | UX & Accessibility Expert |
|
||||||
|
| Code quality & craft | Senior Dart/Flutter Engineer |
|
||||||
|
| Security & supply chain | Security Engineer |
|
||||||
|
| Docs, governance & DX | TPM / Developer-Experience Consultant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall verdict
|
||||||
|
|
||||||
|
clide is, for a solo-dev pre-v2.0 project, **unusually disciplined** — every reviewer
|
||||||
|
said so independently. The governance system is alive, the core subsystems are small
|
||||||
|
and well-typed, the FFI/PTY layer shows real systems-programming care, and the quality
|
||||||
|
gates are genuine rather than ornamental. The codebase is in good shape.
|
||||||
|
|
||||||
|
The weaknesses cluster into a handful of themes, and several are **load-bearing**: a
|
||||||
|
central guardrail (CLI-first IPC) has no runtime implementation, keyboard operability —
|
||||||
|
the core requirement of a power-user dev tool — is largely unbuilt, an untrusted
|
||||||
|
workspace can achieve code execution, and the two primary onboarding documents describe
|
||||||
|
an architecture that no longer exists.
|
||||||
|
|
||||||
|
None of these are fatal; all are fixable; most have quick-win first steps. But they
|
||||||
|
should be addressed before a public v2.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting themes (independent agreement)
|
||||||
|
|
||||||
|
These were each flagged by **two or more** reviewers who did not coordinate:
|
||||||
|
|
||||||
|
1. **The IPC layer is mid-migration and contradicts itself.** The Architect found no
|
||||||
|
Unix-socket *server* anywhere in `lib/` — D-56's "app hosts an in-process IPC server"
|
||||||
|
and D-1's "CLI-first, not MCP" have no runtime path; three IPC clients
|
||||||
|
(`DaemonClient`, `InProcessClient`, `IsolateClient`) coexist with two duplicated
|
||||||
|
service-wiring sites. The Security reviewer independently noted `DaemonClient`'s
|
||||||
|
socket code is still live as an unvalidated attack surface. **Pick one IPC model,
|
||||||
|
implement or amend D-56, delete the other two.**
|
||||||
|
|
||||||
|
2. **The "no pre-existing excuse / clean board" guardrail is being violated right now.**
|
||||||
|
`flutter analyze` reports 9 `unnecessary_import` issues in `test/`; `ci/test.sh` runs
|
||||||
|
analyze with `--no-fatal-infos`, which silently tolerates them. Flagged by the
|
||||||
|
Architect, Code Quality, and Test reviewers. The repo's own rules say fix-first.
|
||||||
|
|
||||||
|
3. **`lib/src/terminal/` is in an undeclared middle state.** ~7k LOC forked from
|
||||||
|
xterm.dart, carrying commented-out `print`s, dangling TODOs, a 1137-line `parser.dart`,
|
||||||
|
and the only `invalid_use_of_protected_member` suppression in the repo. MEMORY says
|
||||||
|
"code under `lib/` is owned, not vendored" — so it must either be formally vendored
|
||||||
|
(frozen, documented, decision-recorded) or cleaned to the project bar. Flagged by
|
||||||
|
Code Quality; the Architect's "consistency" deduction points at the same seam.
|
||||||
|
|
||||||
|
4. **Documentation describes a dissolved architecture.** `README.md` and
|
||||||
|
`docs/initial-plan.md` still describe a Go sidecar, `ptyc/` C helper, `app/`
|
||||||
|
subdirectory, and a separate `clide --daemon` process — all removed by D-5, D-56, and
|
||||||
|
the FFI pivot. A new contributor's first read builds a wrong mental model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consolidated scorecard
|
||||||
|
|
||||||
|
Scores are each reviewer's, 1–5, on their own dimensions.
|
||||||
|
|
||||||
|
| Domain | Dimension | Score |
|
||||||
|
|---|---|---|
|
||||||
|
| **Architecture** | Layering & dependency direction | 4 |
|
||||||
|
| | Separation of concerns | 4 |
|
||||||
|
| | Expandability | 5 |
|
||||||
|
| | Consistency | 4 |
|
||||||
|
| | Guardrail adherence | 3 |
|
||||||
|
| **Tests** | Coverage quality | 4 |
|
||||||
|
| | Test reliability / flakiness | 3 |
|
||||||
|
| | Gate trustworthiness | 3 |
|
||||||
|
| | Test maintainability | 5 |
|
||||||
|
| | Regression-catching power | 4 |
|
||||||
|
| **UX / a11y** | Interaction model | 2 |
|
||||||
|
| | Accessibility | 3 |
|
||||||
|
| | Visual consistency | 4 |
|
||||||
|
| | Discoverability | 2 |
|
||||||
|
| | State coverage (loading/error/empty) | 3 |
|
||||||
|
| **Code quality** | Idiomatic Dart | 4 |
|
||||||
|
| | Error handling | 4 |
|
||||||
|
| | Naming & readability | 4 |
|
||||||
|
| | Consistency across subsystems | 3 |
|
||||||
|
| | Resource / lifecycle safety | 4 |
|
||||||
|
| **Security** | Subprocess safety | 2 |
|
||||||
|
| | IPC input validation | 3 |
|
||||||
|
| | Path / filesystem safety | 3 |
|
||||||
|
| | Dependency / supply-chain hygiene | 3 |
|
||||||
|
| | Secrets & sandboxing | 3 |
|
||||||
|
| **Docs / governance** | Governance discipline | 4 |
|
||||||
|
| | Documentation accuracy | 2 |
|
||||||
|
| | Changelog hygiene | 3 |
|
||||||
|
| | Contributor onboarding | 2 |
|
||||||
|
| | Convention adherence | 4 |
|
||||||
|
|
||||||
|
**Highest marks:** expandability (5), test maintainability (5). The extension contract
|
||||||
|
and test-helper design are genuine standouts.
|
||||||
|
**Lowest marks:** interaction model (2), discoverability (2), subprocess safety (2),
|
||||||
|
documentation accuracy (2), contributor onboarding (2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritized action list
|
||||||
|
|
||||||
|
Synthesized across all six reviews. Severity is the highest any reviewer assigned.
|
||||||
|
|
||||||
|
### Critical — address before public v2.0
|
||||||
|
|
||||||
|
1. **Fix untrusted-workspace code execution.** `toolchain_paths.dart:79` resolves
|
||||||
|
`native/dugite/bin/git` relative to the *workspace root*; a malicious repo can plant
|
||||||
|
an executable there that clide runs on the first auto-fired `git.status`. Resolve
|
||||||
|
`native/dugite` against `Platform.resolvedExecutable`'s directory, never the
|
||||||
|
workspace. *(Security)*
|
||||||
|
2. **Resolve the IPC story.** Either implement the in-process Unix-socket server per
|
||||||
|
D-56 so the `clide` CLI / C client actually works, or amend D-56 to make in-process
|
||||||
|
direct dispatch the design and delete `DaemonClient`'s socket code, `IsolateClient`,
|
||||||
|
`Backend`, and `backend_entry.dart`. Today the code claims three models and runs one,
|
||||||
|
and a load-bearing guardrail (D-1/D-6) is unmet. *(Architecture, Security)*
|
||||||
|
3. **Make the tool keyboard-operable.** `ClideTappable` (base of nearly every
|
||||||
|
interactive widget) is mouse-only — no `Focus`, no Enter/Space. The command palette
|
||||||
|
has no arrow-key navigation and no Escape. For a keyboard-first dev tool this is a
|
||||||
|
functional gap, not a polish item. *(UX)*
|
||||||
|
4. **Fix the onboarding docs.** Rewrite `README.md`'s `ptyc/` / `make ptyc-build`
|
||||||
|
sections, fix its dead `decisions/` link, and banner `docs/initial-plan.md` as
|
||||||
|
historical (or split out a current `docs/architecture.md`). *(Docs)*
|
||||||
|
|
||||||
|
### Major — should land soon
|
||||||
|
|
||||||
|
5. Add symlink re-resolution + containment re-check in `files.read` / `files.ls` — a
|
||||||
|
repo symlink `config -> /etc/shadow` currently passes path-safety. *(Security)*
|
||||||
|
6. Add `test-integration` (and ideally `smoke-bundle`) to `make push-check` — the gate
|
||||||
|
that catches "app won't boot" is currently omitted from the pre-push gate. *(Tests)*
|
||||||
|
7. Schema-validate the IPC argument surface; reject `-`-prefixed `branch`/`remote`/`path`
|
||||||
|
values; add size/count bounds. *(Security)*
|
||||||
|
8. Establish a real focus-traversal model (`FocusTraversalGroup` per slot, a documented
|
||||||
|
"focus next panel" keybinding) and integrate `FocusTracker` with Flutter's focus
|
||||||
|
system instead of paralleling it. *(UX)*
|
||||||
|
9. Fix the `SchedulerService._startTicker` isolate-spawn race — a `_stopTicker()` before
|
||||||
|
the spawn future resolves leaks a forever-ticking isolate. Mirror `NativePty`'s
|
||||||
|
`_readerReady` pattern. *(Code quality)*
|
||||||
|
10. Decide the status of `lib/src/terminal/` — formally vendor (and decision-record) it,
|
||||||
|
or do the cleanup sweep. *(Code quality)*
|
||||||
|
11. Replace fixed wall-clock `Future.delayed` sleeps in `watcher_test.dart` /
|
||||||
|
`session_test.dart` with event-driven waits; make swallowed `onTimeout` callbacks
|
||||||
|
`fail()` loudly. *(Tests)*
|
||||||
|
12. Write a human-facing `CONTRIBUTING.md`; cut an interim release to drain the ~80-commit
|
||||||
|
`[Unreleased]` backlog; merge duplicate changelog subsection headings. *(Docs)*
|
||||||
|
13. Single global `KeyboardListener` → scoped `Shortcuts`/`Actions`; move
|
||||||
|
`KeybindingResolver` off layout-dependent `keyLabel`. *(UX)*
|
||||||
|
|
||||||
|
### Quick wins — hours each
|
||||||
|
|
||||||
|
- Clear the 9 `unnecessary_import` analyzer issues; drop `--no-fatal-infos` from
|
||||||
|
`ci/test.sh`. *(Architecture, Tests, Code quality)*
|
||||||
|
- Run the `forkpty` PTY tests with `--coverage` so `native_pty.dart` — the riskiest file
|
||||||
|
— is honestly measured. *(Tests)*
|
||||||
|
- Add a `Focus` + Enter/Space wrapper and a focus-ring inside `ClideTappable`; this fixes
|
||||||
|
the keyboard gap for every button and list item at once. *(UX)*
|
||||||
|
- Add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||||
|
`_ProjectSwitcherDropdown` `onKeyEvent` pattern). *(UX)*
|
||||||
|
- Amend D-66 to reflect the coverage floor's real location (`pubspec.yaml`), mechanism,
|
||||||
|
and value (90%) — it currently disagrees with the changelog and the code. *(Docs)*
|
||||||
|
- Reconcile `licenses.yaml` with `pubspec.yaml` (`test` version drift, phantom `lints`
|
||||||
|
entry); add a `native/SHA256SUMS` manifest. *(Security, Docs)*
|
||||||
|
- Replace silent `catch (_)` in `tree_sitter_ffi.dart` with a logged last-error.
|
||||||
|
*(Code quality)*
|
||||||
|
- Fix the `clide.dart` barrel leak in `file_tree_view.dart:8`; narrow the barrel (drop
|
||||||
|
the `dispatcher.dart` export); move `test_app.dart` out of the production `main.dart`
|
||||||
|
import graph. *(Architecture)*
|
||||||
|
- Expand the contrast gate's `canonicalPairs` to cover `globalTextMuted`, the `status*`
|
||||||
|
colors, and `panelActiveBorder`. *(UX)*
|
||||||
|
- Triage stale governance Q-records (Q-1/2/3/25 overtaken by shipped Tier-1 work).
|
||||||
|
*(Docs)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Full reviews
|
||||||
|
|
||||||
|
## 1. Architecture — Software Architect
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
clide is an unusually disciplined solo-dev codebase. The governance system (67
|
||||||
|
D-records, tracked Q/R) is real and largely honored in code, the kernel/extension split
|
||||||
|
is coherent, and the feature-first layout with barrel files is consistently applied. The
|
||||||
|
single biggest strength is the **extension contract**: every built-in — including layout
|
||||||
|
itself — passes the same `ClideExtension` + `ContributionPoint` contract, which is the
|
||||||
|
best possible proof the contract is usable. The single biggest risk is **architectural
|
||||||
|
drift in the IPC layer**: D-56 mandates the Flutter app host an in-process IPC server
|
||||||
|
reachable by a thin C client over a unix socket, but no socket server exists anywhere in
|
||||||
|
`lib/` — the "CLI-first, not MCP" guardrail (D-1) has no runtime path today. Compounding
|
||||||
|
this, three parallel IPC client implementations (`DaemonClient` socket,
|
||||||
|
`InProcessClient`, `IsolateClient` + `Backend`) coexist with two competing
|
||||||
|
service-wiring sites (`main.dart` and `backend_entry.dart`), suggesting an unfinished
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **Extension contract is clean and scales** — `lib/extension/src/extension.dart` +
|
||||||
|
`contribution.dart`: sealed `ContributionPoint` hierarchy, `ClideExtensionContext`
|
||||||
|
lists services explicitly (deliberately avoiding a `KernelServices` import cycle —
|
||||||
|
`extension.dart:50-52`). `ExtensionManager` does dependency topo-sort,
|
||||||
|
dependency-gated activation, and contribution apply/remove symmetrically
|
||||||
|
(`extensions_manager.dart:164-202`). Adding a pane = new extension file + one
|
||||||
|
`register()` line in `main.dart`.
|
||||||
|
- **Kernel admission rule is enforced, not aspirational** — D-12's "mandatory shared
|
||||||
|
singleton" test visibly shaped `KernelServices` (`facade.dart:38-93`); ~25 services,
|
||||||
|
each defensibly cross-cutting. The two-tier disable model (D-14) is honored:
|
||||||
|
`default_layout` is itself an extension.
|
||||||
|
- **Feature-first layout with barrel discipline** (D-8) is consistent — every
|
||||||
|
`builtin/<name>/` and `kernel/` has a barrel; builtins import
|
||||||
|
`package:clide/kernel/kernel.dart`, not deep paths. Only one leak found.
|
||||||
|
- **Governance-to-code traceability is genuine** — `WidgetsApp` root (D-7) at
|
||||||
|
`app.dart:38`, `ChangeNotifier`/`ListenableBuilder` state (D-10) everywhere, git
|
||||||
|
hardcoded in toolchain/project loader (D-13), terminal correctly tagged
|
||||||
|
`inlined-source` in `licenses.yaml` with modifications documented.
|
||||||
|
- **Git subsystem cohesion** — `lib/src/git/` cleanly split into `client` / `status` /
|
||||||
|
`diff` / `operations` (~250 lines each), each a single responsibility.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Critical] No IPC socket server exists** — D-56 specifies the app hosts an
|
||||||
|
in-process IPC server with a C client connecting over a unix socket. `grep` for
|
||||||
|
`ServerSocket`/unix-domain `bind` in `lib/` returns nothing. `DaemonClient._connect`
|
||||||
|
(`client.dart:72-94`) *connects* to a socket, but nothing *serves* one. Today the only
|
||||||
|
working path is `InProcessClient` (`in_process.dart`), which calls the dispatcher
|
||||||
|
directly in-process. **Claude cannot drive clide via `clide ...` — the CLI-first
|
||||||
|
guardrail (D-1, D-6) has no implementation.** This is the load-bearing contract of the
|
||||||
|
whole project and it is absent.
|
||||||
|
- **[Major] Three IPC clients + two wiring sites = unfinished migration** —
|
||||||
|
`DaemonClient` (socket), `InProcessClient`, and `IsolateClient`+`Backend`/
|
||||||
|
`backend_entry.dart` all coexist. `main.dart:76-113` wires subsystems via
|
||||||
|
`buildDispatcher`; `backend_entry.dart:40-110` wires the *same* five subsystems again
|
||||||
|
inside an isolate. `Backend.spawn` is referenced only by `facade.dart` but `main.dart`
|
||||||
|
uses `autoStartDaemonClient: false` + `daemonClientFactory` (the in-process path).
|
||||||
|
Dead-or-dormant isolate infrastructure with duplicated registration logic — pick one
|
||||||
|
and delete the others.
|
||||||
|
- **[Major] `main.dart` (production entry) imports `test_app.dart`** — `main.dart:2` and
|
||||||
|
`:51-55`. The production binary carries the test harness and branches on
|
||||||
|
`CLIDE_TESTMODE`. Test scaffolding should not be reachable from the shipping entry
|
||||||
|
point; gate it behind a separate entrypoint or `kDebugMode`.
|
||||||
|
- **[Minor] `flutter analyze` reports 9 issues** — all `unnecessary_import` in `test/`,
|
||||||
|
but CLAUDE.md's "no pre-existing excuse" / "clean board" guardrail makes this a
|
||||||
|
fix-first item.
|
||||||
|
- **[Minor] Barrel leak** — `lib/builtin/files/src/file_tree_view.dart:8` imports
|
||||||
|
`package:clide/src/files/listing.dart` directly instead of via
|
||||||
|
`package:clide/clide.dart` (which already re-exports `FileEntry`).
|
||||||
|
- **[Minor] `clide.dart` barrel exports the daemon dispatcher** — `clide.dart:15`
|
||||||
|
exports `src/daemon/dispatcher.dart`. The barrel is described as "shared types"; the
|
||||||
|
dispatcher is server-side machinery.
|
||||||
|
- **[Minor] `ExtensionManager.activate` swallows exceptions** (`extensions_manager.dart:
|
||||||
|
141-143`) — a failed `activate()` logs and continues, leaving the extension
|
||||||
|
un-activated but `_known`, with no surfaced "degraded" state for the UI.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** clear the 9 analyzer issues; fix the `file_tree_view.dart` barrel leak
|
||||||
|
(consider a CI grep gate for `package:clide/src/` imports outside their feature); move
|
||||||
|
`test_app.dart` out of the production import graph; drop the `dispatcher.dart` export
|
||||||
|
from `clide.dart`.
|
||||||
|
|
||||||
|
**Larger efforts:** resolve the IPC story (implement the socket server per D-56, or
|
||||||
|
amend D-56 and delete `DaemonClient`/`IsolateClient`/`Backend`/`backend_entry.dart`);
|
||||||
|
collapse subsystem wiring into one `registerAllSubsystems(...)` function; give
|
||||||
|
`ExtensionManager` a surfaced failure state so the UI can show degraded built-ins.
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Layering & dependency direction | 4/5 | Kernel→extension direction clean, context-vs-aggregate split avoids cycles; docked for the `src/`↔`kernel/src/` barrel leak and the dispatcher export. |
|
||||||
|
| Separation of concerns | 4/5 | Feature-first layout, single-responsibility subsystems; duplicated subsystem registration is the blemish. |
|
||||||
|
| Expandability | 5/5 | New pane = one extension file + one `register()` line; sealed contribution hierarchy; layout itself is data and extension-shaped. |
|
||||||
|
| Consistency | 4/5 | Barrels, naming, D-record back-references uniform; three coexisting IPC clients and 9 analyzer issues break the bar. |
|
||||||
|
| Guardrail adherence | 3/5 | `WidgetsApp`, single-process, no-Material, governance, zero-deps all honored — but D-1/D-6/D-56 (CLI-first via socket server) have no runtime implementation. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tests & quality gates — Test / QA Analyst
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
The clide test suite is, for a solo-dev pre-2.0 project, in genuinely good shape. ~104
|
||||||
|
test files against 276 lib files, ~92.75% line coverage, and — critically — the coverage
|
||||||
|
was *not* bought with assertion-free filler. Even the alarmingly-named files
|
||||||
|
(`coverage_trivials_test.dart`, `zero_coverage_widgets_test.dart`,
|
||||||
|
`services_stubs_test.dart`, `mop_up_test.dart`) contain real behavioral assertions. The
|
||||||
|
biggest strength is a sensibly layered pyramid with a real boot-path integration gate
|
||||||
|
and a startup smoke test that catches the "tests pass but app won't launch" class. The
|
||||||
|
biggest risk is **flakiness from wall-clock-dependent tests** — fixed `Future.delayed`
|
||||||
|
sleeps in file-watcher and PTY tests will eventually produce intermittent CI failures,
|
||||||
|
and the PTY tests are run via `dart test` so they are **excluded from the coverage
|
||||||
|
measurement entirely**.
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **Test pyramid is sound.** Pure-Dart unit, widget tests with a shared harness, golden
|
||||||
|
tests (Alchemist), an a11y contract layer, and 3 real-boot `integration_test/` files —
|
||||||
|
correctly separated by runner (`ci/test.sh` vs `ci/test_core.sh` vs
|
||||||
|
`ci/test_integration.sh`).
|
||||||
|
- **Helpers are well-designed.** `test/helpers/kernel_fixture.dart` boots a real
|
||||||
|
`KernelServices` with in-memory themes/i18n and `autoStartDaemonClient: false` — no
|
||||||
|
real socket, temp-dir scoped, proper `dispose()`. `FakeDaemonClient` is a clean stub.
|
||||||
|
- **Error-branch discipline.** `pql_commands_errors_test.dart` /
|
||||||
|
`git_commands_errors_test.dart` deliberately point the toolchain at a non-existent
|
||||||
|
binary to drive catch-branches the happy path can't reach — table-driven, with
|
||||||
|
`reason:` tags.
|
||||||
|
- **OS-dialog avoidance is handled correctly.** `welcome/dialog_test.dart` mocks the
|
||||||
|
`clide/window` MethodChannel to throw `MissingPluginException`, exercising the fallback
|
||||||
|
path *without* spawning a native file picker.
|
||||||
|
- **Startup gate.** `ci/smoke_bundle.sh` builds the real release bundle and runs it
|
||||||
|
under xvfb for 5s, correctly interpreting `timeout` exit codes (124/143 = healthy).
|
||||||
|
- **Coverage gate is honest.** `ci/coverage_gate.sh` is a self-contained awk parser (no
|
||||||
|
`lcov` dependency), ratchets only upward, and `exit 2` distinguishes "missing data"
|
||||||
|
from "below floor."
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Major] PTY tests are excluded from coverage.** `ci/test.sh:13` runs
|
||||||
|
`flutter test --coverage --exclude-tags forkpty`; the `forkpty` tests run separately
|
||||||
|
via `dart test` with no `--coverage`. So `lib/src/pty/native_pty.dart` — the
|
||||||
|
highest-risk native code in the repo — is barely in the measured denominator. The
|
||||||
|
92.75% number overstates coverage of the riskiest file.
|
||||||
|
- **[Major] Wall-clock sleeps will flake.** `test/files/watcher_test.dart:67-82` uses
|
||||||
|
fixed `Future.delayed`; `test/pty/session_test.dart:71` polls 50×100ms and `:65` uses a
|
||||||
|
bare `500ms` settle. `session_test.dart`'s `timeout(5s, onTimeout: () {})` (`:48`)
|
||||||
|
*swallows* the timeout — a never-producing PTY proceeds to a confusing assertion
|
||||||
|
failure rather than a clear timeout.
|
||||||
|
- **[Major] `make push-check` does not run integration tests.** `push-check:
|
||||||
|
decisions-validate test-core test test-a11y coverage-gate` — `test-integration` and
|
||||||
|
`smoke-bundle` are omitted. A boot-order regression sails through.
|
||||||
|
- **[Minor] `flutter analyze --no-fatal-infos` in `ci/test.sh:9`** contradicts the
|
||||||
|
stated "fail-on-warning, clean board" discipline.
|
||||||
|
- **[Minor] Integration tests run one-file-at-a-time** to dodge a batch "Unable to start
|
||||||
|
the app" error — each invocation re-boots the engine (slow), and the workaround masks
|
||||||
|
whether the batch failure is environmental or a real teardown leak.
|
||||||
|
- **[Minor] Golden CI config disabled.** Only platform goldens run; a Linux-only CI
|
||||||
|
never validates the macOS goldens, and stale `test/goldens/failures/*.png` artifacts
|
||||||
|
are committed to the repo.
|
||||||
|
- **[Minor] `test_core.sh` timeout kill is best-effort** — the `pkill -9 -f` pattern
|
||||||
|
match is redundant noise next to the real `setsid` + `timeout --kill-after` safety net.
|
||||||
|
- **[Minor] `git/client_test.dart` depends on the ambient `git` binary**, not the
|
||||||
|
vendored dugite — the suite passes/fails on the host git version.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** add `test-integration` (and `smoke-bundle`) to `push-check` — the single
|
||||||
|
highest-value change; run the `forkpty` tests with `--coverage`; drop `--no-fatal-infos`;
|
||||||
|
gitignore `test/goldens/failures/`; make `onTimeout` callbacks `fail()`.
|
||||||
|
|
||||||
|
**Larger efforts:** replace fixed sleeps with event-driven waits
|
||||||
|
(`expectLater(stream, emits(...))`); add a macOS golden CI matrix entry or document
|
||||||
|
goldens as advisory; consider a coverage-exclusion allowlist for genuinely-unreachable
|
||||||
|
defensive branches rather than chasing the last lines with filler tests.
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Coverage quality | 4/5 | Tests are meaningful even in "mop-up" files; docked because PTY/FFI is outside the measured number. |
|
||||||
|
| Test reliability / flakiness | 3/5 | Fixed wall-clock sleeps and a swallowed timeout are latent intermittent failures. |
|
||||||
|
| Gate trustworthiness | 3/5 | Coverage gate and smoke bundle are well-built, but `push-check` omits integration tests. |
|
||||||
|
| Test maintainability | 5/5 | Shared fixtures, consistent structure, table-driven error suites, clear doc comments. |
|
||||||
|
| Regression-catching power | 4/5 | Real boot-path integration + smoke + a11y + goldens; weakened by single-OS goldens and PTY coverage gaps. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UX & accessibility — UX & Accessibility Expert
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
clide has an unusually disciplined *foundation* for a solo pre-v2.0 project: a coherent
|
||||||
|
semantic design-token system, a WCAG-AA contrast gate wired into CI, and i18n/semantic
|
||||||
|
contract tests. That foundation is the biggest strength. The biggest risk is that
|
||||||
|
**keyboard operability is largely unimplemented below the foundation** — the project's
|
||||||
|
own core interaction primitive (`ClideTappable`) is mouse-only, the command palette has
|
||||||
|
no arrow-key navigation or Escape, and there is no focus-traversal wiring across panels.
|
||||||
|
For a keyboard-first power-user dev tool, this is a critical gap that the a11y test suite
|
||||||
|
does not catch because the tests assert *structural* presence (Semantics nodes exist)
|
||||||
|
rather than *operability* (can you actually drive it from the keyboard).
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **Semantic token system is real and enforced.** `lib/kernel/src/theme/tokens.dart`
|
||||||
|
defines ~65 named surface tokens; widgets consume `ClideTheme.of(context).surface`
|
||||||
|
rather than raw colors. The resolver provides defaults so partial themes still produce
|
||||||
|
a complete `SurfaceTokens`.
|
||||||
|
- **Contrast gate is genuine WCAG math, run per-theme.** `lib/kernel/src/theme/
|
||||||
|
contrast.dart` implements real relative-luminance ratio with alpha pre-compositing
|
||||||
|
against neutral grey (`contrast.dart:31-37`) — semi-transparent tokens can't spuriously
|
||||||
|
pass.
|
||||||
|
- **Semantics are present on composed widgets.** `ClideButton` wraps
|
||||||
|
`Semantics(button: true, enabled:, label:, hint:, onTap:)`; panels set
|
||||||
|
`container: true, explicitChildNodes: true` with landmark labels.
|
||||||
|
- **State coverage exists in data panels.** `git_panel_view.dart:86-104` handles error,
|
||||||
|
loading, and empty ("working tree clean") states distinctly; `file_tree_view.dart`
|
||||||
|
handles error + loading.
|
||||||
|
- **Manual a11y discipline is documented.** `docs/testing/a11y-manual.md` prescribes a
|
||||||
|
per-tier Orca/VoiceOver pass and is honest about why prose quality can't be automated.
|
||||||
|
- **Disabled state is handled at the cursor level.** `clide_button.dart:41` switches to
|
||||||
|
`SystemMouseCursors.forbidden` and drops the semantic `onTap` when `onPressed == null`.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Critical] `ClideTappable` is mouse-only — no `Focus`, no keyboard activation.**
|
||||||
|
`lib/widgets/src/clide_tappable.dart:37-54` is `MouseRegion` + `GestureDetector` only.
|
||||||
|
It is the base for `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, and
|
||||||
|
most builtin list items. None can receive Tab focus or be activated with Enter/Space.
|
||||||
|
The keyboard-traversal test only passes because it manually wraps the button in an
|
||||||
|
external `Focus` node — it tests that the widget doesn't *block* focus, not that it
|
||||||
|
*accepts* it.
|
||||||
|
- **[Critical] Command palette is not keyboard-navigable.** `clide_palette.dart` —
|
||||||
|
`onSubmitted` only ever invokes `filtered.first` (`:77-80`); no up/down handling, no
|
||||||
|
selected index, no selection highlight, no Escape handler.
|
||||||
|
- **[Major] No focus-traversal wiring between panels.** `FocusTracker`
|
||||||
|
(`lib/kernel/src/focus.dart`) tracks an active *contribution id* for the `clide active`
|
||||||
|
CLI, but is not Flutter `FocusScope`/`FocusTraversalGroup` integration. Nothing
|
||||||
|
establishes Tab order across sidebar → workspace → context.
|
||||||
|
- **[Major] Drag-resize handles have no keyboard equivalent — parity gap.**
|
||||||
|
`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers, with no
|
||||||
|
Semantics node at all. Per "User/Claude parity", panel sizing should have a CLI
|
||||||
|
affordance; none is evident.
|
||||||
|
- **[Major] Single global `KeyboardListener` is a fragile keybinding architecture.**
|
||||||
|
`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no
|
||||||
|
per-context scoping, will conflict with text-input fields.
|
||||||
|
`KeybindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel`.
|
||||||
|
- **[Major] Text scale is the *only* in-app a11y accommodation, and it's hidden.**
|
||||||
|
`app.dart:122-138` implements Ctrl +/-/0 text scaling but it's undiscoverable. No
|
||||||
|
high-contrast toggle, no reduced-motion handling, no focus-ring rendering anywhere.
|
||||||
|
- **[Minor] Contrast gate covers only 11 token pairs** — omits `globalTextMuted` (muted
|
||||||
|
text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and
|
||||||
|
`panelActiveBorder`.
|
||||||
|
- **[Minor] ~43 hardcoded-color sites bypass the token system** — some defensible (ANSI
|
||||||
|
palette), but the modal/palette shadow and window-control colors won't adapt to the
|
||||||
|
`paper` light theme.
|
||||||
|
- **[Minor] Hover state is inconsistent and not paired with focus** — every interactive
|
||||||
|
widget reimplements its own `_hover` bool; none render a focus indicator.
|
||||||
|
- **[Minor] `_LeftHatContent` is dead code** — `app.dart:281-292` always returns
|
||||||
|
`SizedBox.shrink()`.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** add a `Focus` + `Actions`/`Shortcuts` (Enter/Space → onTap) wrapper and
|
||||||
|
a focus-ring inside `ClideTappable` — fixes the [Critical] for every button/list-item at
|
||||||
|
once; add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||||
|
`_ProjectSwitcherDropdown` `onKeyEvent` pattern at `app.dart:446-452`); expand
|
||||||
|
`canonicalPairs`; surface text-zoom and theme switching in the palette; tokenize the
|
||||||
|
modal shadow and window-control colors.
|
||||||
|
|
||||||
|
**Larger efforts:** establish a real focus-traversal model and integrate `FocusTracker`
|
||||||
|
with Flutter's focus system; replace the root `KeyboardListener` with scoped
|
||||||
|
`Shortcuts`/`Actions` and move off `keyLabel`; add keyboard operability + Semantics to
|
||||||
|
drag-resize handles plus a `clide panel resize` CLI; add an a11y test tier that asserts
|
||||||
|
*operability*, not just Semantics presence.
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Interaction model | 2/5 | Coherent slot/panel structure and good drag-resize *with a mouse*, but keyboard operability is largely unbuilt. |
|
||||||
|
| Accessibility | 3/5 | Genuine contrast gate, Semantics on composed widgets, i18n contract tests — but keyboard operability and focus order are not implemented. |
|
||||||
|
| Visual consistency | 4/5 | Strong semantic token system consumed consistently; a few hardcoded-color sites are real theme-adaptation bugs. |
|
||||||
|
| Discoverability | 2/5 | Command palette isn't keyboard-navigable; accommodations are undiscoverable; no in-app keybinding reference. |
|
||||||
|
| State coverage | 3/5 | Data panels and dialogs handle loading/error/empty; but no focus states anywhere and no reduced-motion handling. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Code quality & craft — Senior Dart/Flutter Engineer
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
clide is, for a solo pre-v2.0 project, in genuinely good shape. The core subsystems (IPC
|
||||||
|
envelope, daemon dispatch, git client, PTY) are small, single-responsibility,
|
||||||
|
well-typed, and consistent. `flutter analyze` is clean for `lib/` — the 9 reported issues
|
||||||
|
are all in `test/`, none are suppressions. The biggest strength is the FFI/PTY layer:
|
||||||
|
`lib/src/pty/native_pty.dart` shows real systems-programming discipline (pre-fork
|
||||||
|
allocation, errno captured before `free`, isolate-teardown ordering documented and
|
||||||
|
correct). The biggest risk is concentrated in two places: a genuine isolate-leak race in
|
||||||
|
`SchedulerService`, and the large vendored-but-owned `lib/src/terminal/` xterm.dart fork
|
||||||
|
(~7k LOC) which carries a different style, commented-out `print`s, and dangling TODOs
|
||||||
|
that the project's own "lib is owned, not vendored" rule says must be held to the same
|
||||||
|
bar.
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **PTY/FFI layer is excellent.** `native_pty.dart:129-145` force-resolves FFI
|
||||||
|
trampolines and pre-allocates *all* native memory before `forkpty()`.
|
||||||
|
`native_pty.dart:171-177` captures `errno` before `_freeAll` because `free()` can
|
||||||
|
clobber it. `close()` (367-397) documents and implements the kill→EOF→close ordering
|
||||||
|
to avoid fd-reuse races. The child branch touches no Dart heap.
|
||||||
|
- **IPC envelope is clean and idiomatic** — `lib/src/ipc/envelope.dart` uses a `sealed`
|
||||||
|
class hierarchy, named constructors, a private unifying constructor, and conditional
|
||||||
|
map keys. Decode is total over the type discriminant.
|
||||||
|
- **Typed, meaningful errors.** `PtyException` carries `op` + optional `errno`;
|
||||||
|
`GitException` carries `stderr`; `errnoToIpcError` maps POSIX errno to actionable IPC
|
||||||
|
error kinds. Errors are values, not strings.
|
||||||
|
- **Resource lifecycle is taken seriously across most subsystems.** `FileWatcher.stop()`
|
||||||
|
cancels the subscription *and* closes the controller; `withBuffer`/`setWinsize` in
|
||||||
|
`libc.dart` use `try/finally` around every native allocation. 45 files define
|
||||||
|
`dispose`/`close`.
|
||||||
|
- **The `DaemonEventSink` interface** keeps the dependency graph pointing the right way
|
||||||
|
(server→subsystems) and is documented as such.
|
||||||
|
- **The one `ignore_for_file` (`libc.dart:11-27`) is exemplary** — textbook FFI case,
|
||||||
|
multi-paragraph justification exactly as CLAUDE.md requires.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Major] Isolate-leak race in `SchedulerService._startTicker`** — `scheduler.dart:71`:
|
||||||
|
`Isolate.spawn(...).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the
|
||||||
|
spawn future completes, `_isolate` is still null, nothing is killed, and the
|
||||||
|
just-spawned isolate (with its `Timer.periodic`) leaks. `native_pty.dart` solved
|
||||||
|
exactly this with `_readerReady`.
|
||||||
|
- **[Major] `lib/src/terminal/` held below the project's own bar.** Carries
|
||||||
|
commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs
|
||||||
|
(`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only
|
||||||
|
`// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`).
|
||||||
|
Either it's genuinely vendored (belongs in `native/` or documented as frozen) or it's
|
||||||
|
owned (needs the cleanup pass).
|
||||||
|
- **[Minor] Empty `catch (_) {}` swallows in `tree_sitter_ffi.dart:197,206`** —
|
||||||
|
`DynamicLibrary.open` failures silently discarded; caller gets a bare `null` with no
|
||||||
|
diagnostic about *why*. Syntax highlighting silently not working is a support
|
||||||
|
headache.
|
||||||
|
- **[Minor] Empty `catch (_) {}` in `test_app.dart:271,311`** — `:271` swallows a
|
||||||
|
theme-load failure the harness exists to detect.
|
||||||
|
- **[Minor] Dead alias in `libc.dart:201-202`** — `typedef Cmsghdr = CmsghdrLinux;`
|
||||||
|
flagged "backward compatibility"; CLAUDE.md forbids backwards-compat hacks in a solo
|
||||||
|
repo.
|
||||||
|
- **[Minor] `// ignore: unused_field` in `editor_controller.dart:25`** "kept for future
|
||||||
|
subscription changes" — speculative retention; the no-suppression rule wants it fixed,
|
||||||
|
not silenced.
|
||||||
|
- **[Minor] Magic numbers in hot FFI paths.** `native_pty.dart` inlines `0x0001
|
||||||
|
// POLLIN`, `28 /* SIGWINCH */`, `4 /* EINTR */`, `9 /* EBADF */` — but `libc.dart`
|
||||||
|
already has a constants section and `errno_mapping.dart` has `PosixErrno.ebadf`.
|
||||||
|
- **[Minor] `git_commands.dart` has ~16 near-identical handler bodies** — a
|
||||||
|
`_guarded(req, () async {...})` helper would remove ~60 lines of structural
|
||||||
|
duplication. Borderline.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** fix the `SchedulerService` spawn race (track the spawn future like
|
||||||
|
`NativePty._readerReady`); replace the three silent `catch (_)` in `tree_sitter_ffi.dart`
|
||||||
|
with a logged last-error; delete the `Cmsghdr` alias and the `unused_field` suppression;
|
||||||
|
have the PTY layer consume `libc.dart` constants / `PosixErrno` instead of inline hex.
|
||||||
|
|
||||||
|
**Larger efforts:** decide the status of `lib/src/terminal/` — formally vendor it
|
||||||
|
(freeze, document, decision-record) or do the cleanup sweep; optionally a `_guarded`
|
||||||
|
helper for `git_commands.dart` (check whether `files_commands` / `editor_commands` share
|
||||||
|
the shape).
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Idiomatic Dart | 4/5 | Sealed classes, named ctors, records, `const`, immutability used well; the vendored terminal tree pulls the average down. |
|
||||||
|
| Error handling | 4/5 | Typed errors with context everywhere in core; a few silent `catch (_)` in the FFI loader and test harness cost the 5th point. |
|
||||||
|
| Naming & readability | 4/5 | Clear, intention-revealing names; comments earn their place; inline magic numbers are the main blemish. |
|
||||||
|
| Consistency across subsystems | 3/5 | IPC/git/files/pty are uniform; `lib/src/terminal/` is a different codebase in style; PTY duplicates constants `libc.dart` owns. |
|
||||||
|
| Resource/lifecycle safety | 4/5 | `try/finally` around native allocs, controllers closed, subscriptions cancelled; the one real defect is the `SchedulerService` race. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security & supply chain — Security Engineer
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
clide's security posture is **above average for a solo pre-v2.0 project**. All
|
||||||
|
subprocess calls use `Process.run`/`Process.start` with argument *lists* (no shell
|
||||||
|
interpolation), the IPC transport is a per-user Unix socket (not a TCP port), and there
|
||||||
|
is an explicit `path_safety` module with a containment check. The single biggest strength
|
||||||
|
is the disciplined no-shell subprocess layer. The single biggest risk is
|
||||||
|
**untrusted-workspace code execution via toolchain resolution**
|
||||||
|
(`toolchain_paths.dart:79`): a malicious repo can ship a `native/dugite/bin/git`
|
||||||
|
executable that clide will resolve and run. Secondary real issues: path-safety does not
|
||||||
|
defend against symlink escape, and IPC command args are largely unvalidated/un-bounded.
|
||||||
|
Supply-chain hygiene is mostly good but `licenses.yaml` has drifted from `pubspec.yaml`
|
||||||
|
and native binaries are committed without SHA pinning.
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **No-shell subprocess execution.** `GitClient._run` (`client.dart:210`),
|
||||||
|
`PqlClient._run` (`client.dart:165`), and the PTY layer all pass `List<String>` args
|
||||||
|
directly. Classic command injection is structurally prevented.
|
||||||
|
- **Toolchain uses resolved absolute paths** — git/pql/tmux resolved once to absolute
|
||||||
|
paths and reused.
|
||||||
|
- **Path containment check exists and is used.** `resolveUnderRoot`
|
||||||
|
(`path_safety.dart:21`) collapses `..`/`.` without touching the filesystem and enforces
|
||||||
|
a prefix check with a separator guard. `files.read`/`files.ls` both call it.
|
||||||
|
- **IPC is a per-user Unix socket, not a network listener.** No `ServerSocket` over TCP
|
||||||
|
anywhere; the default runtime path is in-process, eliminating the socket attack
|
||||||
|
surface in the shipped app.
|
||||||
|
- **PTY FFI memory discipline** — all native memory allocated before `forkpty()`, `errno`
|
||||||
|
captured before `free()`, freed on every path.
|
||||||
|
- **`pubspec.lock` is committed**, deps use exact pins (no carets), `licenses.yaml`
|
||||||
|
exists with per-dep purpose/license.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Critical] Malicious workspace can plant a git binary that clide executes.**
|
||||||
|
`toolchain_paths.dart:79-84` builds `'$workspaceRoot/native/dugite/bin'` and runs
|
||||||
|
`_firstExisting(['$dugite/git'])`; if that file exists it becomes the git binary for
|
||||||
|
all `GitClient` calls, **before** falling back to PATH. An attacker commits an
|
||||||
|
executable at `native/dugite/bin/git`; clide runs it on the first `git.status` (which
|
||||||
|
fires automatically on workspace open). Arbitrary code execution from merely opening a
|
||||||
|
repo. The `native/dugite` convention should resolve relative to the *clide install
|
||||||
|
dir*, never the workspace root.
|
||||||
|
- **[Major] Path-safety does not defend against symlink escape.** `path_safety.dart:
|
||||||
|
35-51` explicitly does not resolve symlinks, and the filesystem layer
|
||||||
|
(`files_commands.dart:81-85`) never does either. A repo symlink `config -> /etc/shadow`
|
||||||
|
passes the containment check (the *link path* is under root) and clide reads the
|
||||||
|
target. Fix: after `resolveUnderRoot`, `resolveSymbolicLinksSync()` and re-verify
|
||||||
|
containment.
|
||||||
|
- **[Major] IPC command arguments are unvalidated and unbounded.**
|
||||||
|
`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson`
|
||||||
|
(`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count
|
||||||
|
cap on `git.log`, no check that `git.checkout`'s `branch` (`git_commands.dart:240`)
|
||||||
|
isn't a `-`-prefixed flag. `git diff`/`stage` use `--` separators (good), but
|
||||||
|
`checkout(branch)` and `push(remote, branch)` do not — argument injection
|
||||||
|
(`git checkout --upload-pack=...`) is possible.
|
||||||
|
- **[Minor] macOS entitlements disable library validation.**
|
||||||
|
`macos/Runner/Release.entitlements` sets `disable-library-validation` = true with no
|
||||||
|
App Sandbox entitlement. Arguably needed for the `dlopen` of `libtree-sitter.so`, but
|
||||||
|
combined with no sandbox a compromised process has full user-level filesystem access.
|
||||||
|
- **[Minor] `licenses.yaml` has drifted from `pubspec.yaml`.** Lists dev-dep `test` at
|
||||||
|
`1.25.8` but `pubspec.yaml:60` pins `1.30.0`; lists a `lints 5.0.0` not in
|
||||||
|
`pubspec.yaml` at all. The two-step-commit guardrail is being violated.
|
||||||
|
- **[Minor] Native binaries committed without SHA pinning.** `native/linux-x64/` has
|
||||||
|
`libtree-sitter.so` (24 MB) and `ptyc` (22 KB) committed with no `SHA256SUMS` manifest.
|
||||||
|
CLAUDE.md says native deps are "pinned by SHA"; that pinning is not evidenced.
|
||||||
|
- **[Informational] No secrets service** — clide stores no tokens; git auth is delegated
|
||||||
|
to the system credential helper. The right call; noted so the absence reads as
|
||||||
|
deliberate.
|
||||||
|
- **[Informational] Lua runtime is a stub** — `lib/lua/src/host.dart` is Tier-0. Design
|
||||||
|
intent (strip `io`/`os.execute`/`package.loadlib`/`debug`) is sound; re-assess at Tier
|
||||||
|
6 — sandbox-escape via FFI re-entry will be the concern.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** fix toolchain resolution to resolve `native/dugite` against
|
||||||
|
`Platform.resolvedExecutable`'s directory, never `workspaceRoot` (closes the Critical);
|
||||||
|
add symlink re-check in `files.read`/`files.ls`; reconcile `licenses.yaml` with
|
||||||
|
`pubspec.yaml`; reject `-`-prefixed values for `branch`/`remote`/`path` args (or use
|
||||||
|
`--` everywhere, including `checkout`).
|
||||||
|
|
||||||
|
**Larger efforts:** schema-validate the IPC surface with typed arg schemas + size/count
|
||||||
|
bounds; add a committed `native/SHA256SUMS` verified by `make` and CI; revisit macOS
|
||||||
|
sandboxing (App Sandbox with explicit exceptions); security-review the Lua FFI boundary
|
||||||
|
and capability table before Tier 6 ships.
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Area | Rating | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Subprocess safety | 2/5 | No-shell arg lists are excellent, but the workspace-relative dugite path is a real RCE; argument-injection on `checkout`/`push` unmitigated. |
|
||||||
|
| IPC input validation | 3/5 | Per-user Unix socket + in-process default sharply limits exposure, but zero arg-schema validation and no size/count bounds. |
|
||||||
|
| Path/filesystem safety | 3/5 | Real containment check that's actually wired in, undermined by the unhandled symlink-escape gap. |
|
||||||
|
| Dependency/supply-chain hygiene | 3/5 | Exact pins, committed lockfile, documented deps — but `licenses.yaml` drift and missing SHA manifest for committed native binaries. |
|
||||||
|
| Secrets & sandboxing | 3/5 | Correctly delegates secrets; Lua sandbox is only a stub; macOS runs with library validation off and no App Sandbox. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Docs, governance & DX — TPM / Developer-Experience Consultant
|
||||||
|
|
||||||
|
### Executive summary
|
||||||
|
|
||||||
|
clide runs an unusually disciplined governance system for a solo-dev pre-v2.0 project:
|
||||||
|
67 decision records across six domains, with a parser-validated DQR structure, anchored
|
||||||
|
cross-references, and a `make decisions-validate` gate wired into pre-push. The biggest
|
||||||
|
strength is that the DQR system is genuinely *alive* — questions get resolved with dated
|
||||||
|
amendments, superseded decisions are marked, and decisions cite the commits that
|
||||||
|
implement them. The biggest risk is **documentation drift in the narrative docs**:
|
||||||
|
`README.md` and `docs/initial-plan.md` describe an architecture (Go sidecar, `ptyc/` C
|
||||||
|
helper, `app/` subdirectory, separate daemon) that three major decisions (D-5, D-56, the
|
||||||
|
FFI pivot) have since dissolved. A new contributor reading the README first would build
|
||||||
|
a wrong mental model.
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **DQR system is maintained, not ornamental.** Resolved questions carry dated
|
||||||
|
resolution lines pointing to the deciding D-record (`questions/architecture.md:39`
|
||||||
|
Q-6→D-57). D-40 carries a `[SUPERSEDED]` tag and an amendment line.
|
||||||
|
- **Decisions are linked to code and commits.** D-67 (`decisions/process.md:61`) cites
|
||||||
|
implementing commits `01a99ed`, `d162ba2`. D-66 references `ci/test.sh` by path.
|
||||||
|
- **Governance migration was done cleanly** — the `decisions/` → `governance/`
|
||||||
|
restructure updated cross-references and the auto-generated index.
|
||||||
|
- **Commit discipline is real.** `git log` shows imperative subjects, no Conventional
|
||||||
|
Commits prefixes, ticket refs, logical scoping — exactly what `git-commit/SKILL.md`
|
||||||
|
prescribes.
|
||||||
|
- **`licenses.yaml` is thorough** — all six runtime Dart deps present, plus fonts/native
|
||||||
|
libs, with purpose justifications. *(Note: the Security reviewer found version drift
|
||||||
|
in this file — see Finding above; the two reviewers examined different rows.)*
|
||||||
|
- **Makefile is self-documenting** (`##` help annotations) and matches `CLAUDE.md`.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- **[Critical] `docs/initial-plan.md` is badly stale.** The "north-star" doc (linked
|
||||||
|
from `CLAUDE.md:14` and `README.md:44`) still describes a Go sidecar
|
||||||
|
(`initial-plan.md:4,55,189`), `clide --daemon` long-running process (`:162-164`),
|
||||||
|
`app/` subdirectory layout (`:184-204`), and `project.yaml` (`:172`) — all contradicted
|
||||||
|
by D-5, D-56, and the single-package-at-root reality. Nothing flags it as historical.
|
||||||
|
- **[Critical] `README.md` describes a dissolved architecture.** `README.md:10`
|
||||||
|
documents `ptyc/` as a live component; `README.md:36` lists `make ptyc-build`. The
|
||||||
|
`ptyc/` directory does not exist, the Makefile has no such target, and the CHANGELOG's
|
||||||
|
own Unreleased section records ptyc's removal.
|
||||||
|
- **[Major] `README.md:44` links to `decisions/`** — a directory that no longer exists
|
||||||
|
(migrated to `governance/`). Dead link in the primary onboarding doc.
|
||||||
|
- **[Major] CHANGELOG has duplicate subsection headings in `[Unreleased]`.** Three
|
||||||
|
`### Changed` blocks (`CHANGELOG.md:100, 114, 168`), two `### Fixed`, two `### Removed`
|
||||||
|
in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release.
|
||||||
|
- **[Major] No `CONTRIBUTING.md` or onboarding doc.** For a project "intended to ship
|
||||||
|
publicly to other developers," there is no contributor guide; the build/test story is
|
||||||
|
scattered across `CLAUDE.md` (Claude-oriented), `README.md` (partly wrong), and
|
||||||
|
Makefile help.
|
||||||
|
- **[Major] Coverage-floor governance contradicts itself.** D-66 (`testing.md:65`) says
|
||||||
|
the floor lives at `coverage/floor.txt` starting "≈35%"; `CHANGELOG.md:44-46` says it's
|
||||||
|
in `pubspec.yaml` `coverage_floor:` starting at 34%; the latest commit is `9030e56
|
||||||
|
hold coverage_floor fixed at 90`. Three sources, three mechanisms/values. D-66 was
|
||||||
|
never amended.
|
||||||
|
- **[Minor] `ci/release.sh` is a stub that still references goreleaser/sidecar** — Go
|
||||||
|
tooling for a project with no Go.
|
||||||
|
- **[Minor] CHANGELOG `[2.0.0] — 2026-05-03` dating** — the v2.0.0 tag is dated
|
||||||
|
2026-05-03 but the enormous Unreleased section represents ~80 commits of post-tag work
|
||||||
|
with no interim version.
|
||||||
|
- **[Minor] Stale-ish open questions** — Q-25 (body text face) is de facto resolved by
|
||||||
|
D-43/D-44 and the shipped impl; Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are
|
||||||
|
due for triage now that Tier 1 has shipped.
|
||||||
|
- **[Minor] Skills system is coherent but undocumented as a set** — eight skills under
|
||||||
|
`.claude/skills/`, no index.
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
**Quick wins:** rewrite `README.md`'s `ptyc/` sections and fix the `decisions/` link;
|
||||||
|
banner `docs/initial-plan.md` as historical (or split out a current
|
||||||
|
`docs/architecture.md`); merge the duplicate changelog subsection headings; amend D-66 to
|
||||||
|
reflect the floor's actual location/mechanism/value with a dated amendment line; triage
|
||||||
|
Q-1/2/3/25.
|
||||||
|
|
||||||
|
**Larger efforts:** write a human-facing `CONTRIBUTING.md` (clone →
|
||||||
|
`make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions); cut
|
||||||
|
an interim release to drain the ~80-commit Unreleased backlog; add a
|
||||||
|
`.claude/skills/README.md` inventory; establish a periodic governance sweep (the repo
|
||||||
|
even has a `clean-house` skill for exactly this).
|
||||||
|
|
||||||
|
### Scorecard
|
||||||
|
|
||||||
|
| Area | Score | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Governance discipline | 4/5 | DQR system genuinely maintained — but D-66 drift and untriaged Tier-1-era questions show the sweep cadence lags the code. |
|
||||||
|
| Documentation accuracy | 2/5 | Both primary onboarding docs describe a dissolved Go-sidecar/ptyc/daemon architecture; `CLAUDE.md` is accurate by contrast. |
|
||||||
|
| Changelog hygiene | 3/5 | Per-commit discipline is followed, but duplicate subsection headings violate the standard and an 80-commit Unreleased backlog undermines the format. |
|
||||||
|
| Contributor onboarding | 2/5 | No `CONTRIBUTING.md`; build story split across three docs, one wrong; `CLAUDE.md` is Claude-addressed, not human-addressed. |
|
||||||
|
| Convention adherence | 4/5 | Commit style, DQR claiming, `licenses.yaml` two-step rule demonstrably followed; docked for the changelog defects and the README gap. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Closing note
|
||||||
|
|
||||||
|
The recurring pattern across all six reviews: **clide's foundations are excellent and
|
||||||
|
its finishing is incomplete.** The extension contract, test helpers, FFI discipline,
|
||||||
|
governance system, and token system are all things most projects never get right. The
|
||||||
|
gaps — IPC not wired, keyboard not operable, docs describing a dead architecture, a
|
||||||
|
workspace-relative binary path — are all the kind of thing that happens when a fast-moving
|
||||||
|
solo project's implementation outruns its connective tissue. They are concentrated, not
|
||||||
|
diffuse, and the quick-win column above would close most of the critical ones in a few
|
||||||
|
focused days.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Declares tags used by inline `test('...', tags: [...], ...)` calls so
|
||||||
|
# that `flutter test --exclude-tags <name>` and `dart test --tags <name>`
|
||||||
|
# both honor them. Undeclared tags are ignored by the runners, which
|
||||||
|
# silently breaks selective excludes.
|
||||||
|
tags:
|
||||||
|
# Native-PTY tests (posix_openpt + posix_spawn via Dart FFI). Must run
|
||||||
|
# under `dart test`, not `flutter test`: the flutter-test runner hosts a
|
||||||
|
# multi-threaded engine in which the PTY master fd doesn't reliably
|
||||||
|
# deliver output, so these flake there (verified). They are reliable
|
||||||
|
# under `dart test`. (Tag was historically named `forkpty`, before
|
||||||
|
# T-96 replaced forkpty with posix_spawn.) See `test/pty/session_test.dart`.
|
||||||
|
pty:
|
||||||
|
# Concurrency-vulnerable tests that flake in the parallel flutter pool
|
||||||
|
# (e.g. MessageBus delivery races). The parallel run excludes them
|
||||||
|
# (`--exclude-tags "pty || serial"`); ci/test.sh runs a separate
|
||||||
|
# `--tags serial --concurrency=1` pass and merges its coverage. Prefer a
|
||||||
|
# deterministic await first — only tag `serial` when isolation is the real
|
||||||
|
# fix. (T-193)
|
||||||
|
serial:
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Decisions
|
|
||||||
|
|
||||||
Confirmed decisions, open questions, and rejected alternatives for clide.
|
|
||||||
|
|
||||||
Decisions are split by domain. When unsure where a record belongs: if
|
|
||||||
it constrains **how we build**, it's architecture. If it defines **what
|
|
||||||
ships to users**, it's extensions / accessibility. If it defines **how
|
|
||||||
we verify**, it's testing. If it defines **what the toolchain looks
|
|
||||||
like**, it's tooling. If it defines **how the team works**, it's
|
|
||||||
process.
|
|
||||||
|
|
||||||
Cross-domain records live in one file with `[D-NNN]`-shaped cross-
|
|
||||||
references in related files. Split threshold: when any file exceeds
|
|
||||||
~350 lines, review whether it should split (see settled-reach's
|
|
||||||
`questions-*.md` split pattern for precedent).
|
|
||||||
|
|
||||||
## Domain files
|
|
||||||
|
|
||||||
| File | Domain |
|
|
||||||
|------|--------|
|
|
||||||
| [architecture.md](architecture.md) | Core, rendering, IPC, kernel, panel manager |
|
|
||||||
| [extensions.md](extensions.md) | Extension contract, Lua runtime, grain, contribution points |
|
|
||||||
| [accessibility.md](accessibility.md) | A11y + i18n policy, WCAG gates |
|
|
||||||
| [testing.md](testing.md) | Test pyramid, drivers, client-side constraint |
|
|
||||||
| [tooling.md](tooling.md) | Toolchain, supply chain, CI, ignore strategy |
|
|
||||||
| [process.md](process.md) | Q&D system, kanban, commit conventions, changelog |
|
|
||||||
| [rejected.md](rejected.md) | Rejected alternatives across all domains |
|
|
||||||
| [questions.md](questions.md) | Master index of open questions |
|
|
||||||
| [questions-architecture.md](questions-architecture.md) | Architecture Qs |
|
|
||||||
| [questions-extensions.md](questions-extensions.md) | Extension Qs |
|
|
||||||
| [questions-accessibility.md](questions-accessibility.md) | A11y / i18n Qs |
|
|
||||||
| [questions-testing.md](questions-testing.md) | Testing Qs |
|
|
||||||
| [questions-process.md](questions-process.md) | Process + tooling Qs |
|
|
||||||
|
|
||||||
## Record shape
|
|
||||||
|
|
||||||
Confirmed decisions (`D-NNN`):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### D-NNN: Short title
|
|
||||||
- **Date:** YYYY-MM-DD
|
|
||||||
- **Decision:** one-sentence summary, then details.
|
|
||||||
- **Rationale:** why this over alternatives.
|
|
||||||
- **Cost:** known downsides / what we're accepting.
|
|
||||||
- **Raised by:** who proposed / endorsed.
|
|
||||||
```
|
|
||||||
|
|
||||||
Domain-specific fields (`Kill switch:`, `Evaluation reports:`,
|
|
||||||
`Amendment:`, `Cross-reference:`) are additive. Amendments are inline
|
|
||||||
and dated: `**Amendment (YYYY-MM-DD):** …`. Cross-references use
|
|
||||||
markdown anchor links with the full slug:
|
|
||||||
`[D-5](architecture.md#d-5-dart-core-ptyc-peer)`.
|
|
||||||
|
|
||||||
Open questions (`Q-NNN`):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### Q-NNN: Short question-form title
|
|
||||||
- **Status:** Open | Partially resolved → [D-NNN] | Resolved → [D-NNN]
|
|
||||||
- **Question:** ...
|
|
||||||
- **Context:** ...
|
|
||||||
- **Assigned to:** (optional)
|
|
||||||
- **Source:** (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
Rejected alternatives (`R-NNN`):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### R-NNN: Short rejected-option title
|
|
||||||
- **Rejected:** YYYY-MM-DD
|
|
||||||
- **Reason:** ...
|
|
||||||
- **Cross-reference:** [D-NNN] (what was picked instead)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Claiming an ID
|
|
||||||
|
|
||||||
Until the pql planning subcommands land ([`Q-21`](questions-process.md)),
|
|
||||||
claim IDs by inspecting the highest existing `D-NNN` / `Q-NNN` /
|
|
||||||
`R-NNN` in the target file and incrementing.
|
|
||||||
|
|
||||||
Once `pql decisions claim D <domain> "title"` exists, use that —
|
|
||||||
same semantics, no race on concurrent sessions.
|
|
||||||
|
|
||||||
## Querying
|
|
||||||
|
|
||||||
`pql decisions …` reads `decisions/*.md` and writes `.pql/pql.db`
|
|
||||||
(gitignored; markdown is the source of truth).
|
|
||||||
|
|
||||||
Common queries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pql decisions list --type confirmed --domain architecture
|
|
||||||
pql decisions show D-5 --with-refs
|
|
||||||
pql decisions coverage # D-records without tickets
|
|
||||||
pql decisions validate # pre-push parser gate
|
|
||||||
pql ticket board # kanban view of tickets
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a decision
|
|
||||||
|
|
||||||
1. Edit the appropriate domain file.
|
|
||||||
2. Follow the record shape above.
|
|
||||||
3. Run `pql decisions validate` (also runs in `make push-check`).
|
|
||||||
4. Commit. The SQLite index rebuilds from markdown on any
|
|
||||||
`pql decisions sync`.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Accessibility + i18n Decisions
|
|
||||||
|
|
||||||
A11y + i18n are Tier-0 contracts, not Tier-6 polish.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### D-20: A11y is a Tier-0 contract
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Every widget primitive wraps its interaction surface in a `Semantics` node at the point of creation. A11y coverage is a test-time gate (`ci/test_a11y.sh`), not a post-hoc polish pass. `ensureSemantics()` fires at app boot; Flutter's semantics tree is always populated.
|
|
||||||
- **Rationale:** Retrofitting a11y onto a grown UI is what every project that skips this promises to do later and then doesn't. Making it a Tier-0 contract costs one `Semantics` line per primitive and a semantic-coverage test; postponing costs a rewrite.
|
|
||||||
- **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `app/test/a11y/` coverage tests.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-21: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain)
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** All user-facing strings resolve through a namespaced i18n catalogue loader ported from fframe's text-driven pattern, extended with a locale-fallback chain fframe lacks. JSON per locale; `I18n.of(context).t('namespace.key', {vars})`. Missing keys resolve down the chain (e.g. `en_GB` → `en` → default), never fail silently; missing at the base locale logs a dev-mode error.
|
|
||||||
- **Rationale:** Flutter's `intl` + ARB codegen is inflexible for plugin-contributed catalogs (see [R-4](rejected.md#r-4-flutter-intl-and-arb-codegen)) — we need per-extension catalogs that merge without a codegen step. fframe's shape fits; its silent-fallback behaviour does not, so we add the chain.
|
|
||||||
- **Cost:** JSON has no comments and no trailing commas; translation tooling has to accept that. Separate `i18n` facade on every feature.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-22: WCAG-AA contrast gate on bundled themes
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Every bundled theme must pass a WCAG-AA contrast check on its canonical token pairs (text/background, link/background, focus-ring/background) at test time. `ci/test_a11y.sh` runs the gate; CI fails on regressions.
|
|
||||||
- **Rationale:** Themes drift under "looks nicer" tweaks; contrast regressions land silently. Running the gate on every PR is the cheapest insurance. Ran the gate on initial themes — caught one summer-night muted token at 2.81:1 (below AA), fixed before landing.
|
|
||||||
- **Cost:** Third-party themes (Tier 6) won't be gated until an extension-time test hook lands. Bundled themes are gated today.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# Architecture Decisions
|
|
||||||
|
|
||||||
Core, rendering, IPC, kernel, panel manager.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### D-7: App root is bare `WidgetsApp`
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** The Flutter app root is `WidgetsApp`, not `MaterialApp` or `CupertinoApp`. Clide's look is fully custom; the Material/Cupertino shells would drag in opinionated theming, default icons, and platform chrome we'd then have to fight.
|
|
||||||
- **Rationale:** Clide is a Linux-primary desktop IDE with a custom theme pipeline and custom primitives (panels, tabs, panes, canvas). Material's implicit theming collides with [D-9](#d-9-three-tier-theme-pipeline); Cupertino is iOS-flavoured. `WidgetsApp` gives us routing, locale, focus traversal, semantics, and Directionality without aesthetic baggage.
|
|
||||||
- **Cost:** We build and own every primitive; no `ElevatedButton` fallback. See [R-3](rejected.md#r-3-materialapp-root) and [R-7](rejected.md#r-7-cupertinoapp-root).
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-8: Feature-first folder layout
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Under `app/lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `app/lib/kernel/kernel.dart`).
|
|
||||||
- **Rationale:** Features grow and get deleted as units; layer-first layouts fragment a feature across three directories and make deletions risky. Matches extensions-as-features (every extension already has its own folder).
|
|
||||||
- **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-9: Three-tier theme pipeline
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Themes resolve through three layers: (1) palette — raw named colours per theme YAML; (2) semantic — roles like `surface.background`, `text.primary`, `accent.focus`; (3) surface — component-scoped tokens derived from semantic roles (button bg/fg/border hover/pressed/disabled states).
|
|
||||||
- **Rationale:** Direct palette-to-component binding collapses under multi-theme work; VS Code's 600-token surface map is the proof. The semantic layer is where a11y contrast gates apply; the surface layer is where components bind.
|
|
||||||
- **Cost:** Three layers to keep coherent per theme. Contrast gate ([D-22](accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes)) enforces the semantic layer on every bundled theme.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
|
|
||||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks-mocktail-at-io-plus-hand-rolled-fakes)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](rejected.md#r-8-riverpod-provider-bloc-for-state).
|
|
||||||
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-11: Panel manager is kernel; layout is data; three-column is a preset
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** The kernel owns a panel manager that treats layout as declarative data (tree of splits + leaves). The default "three-column IDE" (sidebar / editor / assistant) is one preset; alternative presets (writer-focus single-column, debugger four-pane) ship as data, not code forks.
|
|
||||||
- **Rationale:** Hard-coded three-column layouts paint us into corners when future tiers add canvas, graph, terminal-grid. Data-driven layout also lets extensions contribute presets without patching the panel manager.
|
|
||||||
- **Cost:** More kernel surface up-front; pays back at Tier 5 (canvas) and Tier 6 (extension-contributed layouts).
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-12: Kernel admission rule — mandatory shared singletons only
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** A service joins the kernel only if it is (a) mandatory for app boot and (b) a shared singleton across features. Everything else is an extension or a feature-local service.
|
|
||||||
- **Rationale:** Keeps the kernel auditable. Previous drafts piled "useful globals" into the kernel; result was a 40-service god-object. The admission rule forced 18 services out of 31 candidates.
|
|
||||||
- **Cost:** Some legitimate cross-cutting concerns (telemetry, crash reporter when they land) must pass the test; we expect a few more admissions as Tiers 3-6 land.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-13: Git hardcoded in kernel project-loader
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** The kernel's project loader treats "repo root" as a `git` concept — runs `git rev-parse --show-toplevel` to find workspace root, subscribes to filesystem events, and shells out to `git` for status/diff/stage. No VCS abstraction layer.
|
|
||||||
- **Rationale:** Option B (VCS abstraction) is premature generalisation — we have one VCS today, Mercurial/Fossil/Sapling users are a rounding error on the Linux desktop IDE market, and the abstraction adds a seam that has to be tested against nothing. When a second VCS shows up we refactor.
|
|
||||||
- **Cost:** Adding Mercurial support later costs a real refactor, not just a plugin. Acceptable.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-14: Two-tier disable — kernel locked, everything else extension-shaped
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Kernel services cannot be disabled at runtime. Extensions (including every bundled built-in) can be toggled via the extension manager. This creates exactly two disable tiers: kernel (always on) and extension (toggleable).
|
|
||||||
- **Rationale:** A three-tier system (kernel / bundled-cannot-disable / user-can-disable) is dishonest — if a "bundled built-in" can't be disabled, it's kernel and belongs in kernel admission review. Forcing every bundled feature to pass the extension contract is also the best test we have that the contract is actually usable.
|
|
||||||
- **Cost:** Disabling `builtin.default_layout` by mistake produces an empty window. Mitigated by the kernel's first-boot defaults and a "reset extensions" action.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### D-41: Claude panes — one primary per repo, tmux-backed
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Every repo (keyed on the git root) hosts **exactly one primary Claude pane** plus zero or more **secondary** Claude panes. The primary persists across clide restarts; secondaries are ephemeral. Persistence layer is **tmux**: the daemon spawns the primary as `tmux new-session -A -s clide-claude-<repohash> -- claude`, which re-attaches to the running session if the app restarts. Secondaries spawn as `tmux new-session -A -s clide-claude-<repohash>-N -- claude` with `N` incrementing. Close semantics: closing a secondary kills that tmux session and focus collapses back to the primary (or to the next-most-recent secondary); the primary has **no close affordance** — close-gestures on it hide it / minimise to a dock, they don't kill the session. Daemon is the owner; the UI doesn't track tmux session state directly, it just asks the pane subsystem to spawn/close and observes events. General-purpose terminal panes (`builtin.terminal`) do **not** get tmux wrapping or persistence — they're per-app-lifetime.
|
|
||||||
- **Context:** 2026-04-22 planning. The user workflow is "open repo → Claude is already there, with my last conversation intact." A cold session-restart every time clide re-launches defeats the premise. tmux already solves "reattach to a shell-like session across disconnects"; layering our own persistence protocol on top of ptyc would duplicate it.
|
|
||||||
- **Rationale:** (1) tmux is battle-tested — no new persistence code to review. (2) The pane subsystem stays neutral; Claude-specific behaviour lives in `builtin.claude`. (3) Keying by git root means the user doesn't manage session names manually — opening a repo is enough. (4) "Always one primary" removes a failure mode: there's never "no Claude to talk to." (5) Secondaries stay frictionless — the user spawns and closes them at will without breaking the primary.
|
|
||||||
- **Cost:** Requires tmux on the PATH of the daemon's runtime environment (reasonable for Linux + macOS; Windows support via WSL or a separate approach). Killing a primary (via the daemon on shutdown) still leaves the detached tmux session around until the next clide start re-attaches; acceptable but worth documenting for support. Secondary numbering (`-1`, `-2`, …) resets between clide runs since ephemeral state is lost — also acceptable.
|
|
||||||
- **Raised by:** 2026-04-22 planning, Tier 1 implementation.
|
|
||||||
- **Cross-reference:** [`D-5`](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (ptyc as the spawn primitive tmux runs under), [`D-6`](#d-6-cli-and-event-surface-contract) (pane.\* IPC surface), [`R-9`](rejected.md#r-9-port-planning-tooling-into-clide) (why per-repo scoping via git root matches the wrap-don't-duplicate theme).
|
|
||||||
|
|
||||||
### D-1: CLI-first, not MCP
|
|
||||||
- **Date:** 2026-04-20 (was ADR 0001; ported from the claudian lineage)
|
|
||||||
- **Decision:** Claude talks to clide exclusively via Bash (`clide …`). No MCP server. No protocol layer in Claude's face. The CLI uses the same exit-code + stderr-JSON contract as pql.
|
|
||||||
- **Context:** The two mainstream options for the agent-facing surface were an MCP server or a plain Bash CLI matching pql's contract.
|
|
||||||
- **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-6](#d-6-cli-and-event-surface-contract)). Claude Code's `Bash(clide *)` allow rule is the only configuration clide needs on the agent side.
|
|
||||||
- **Cost:** If an MCP-only integration becomes compelling later (e.g. a multi-agent scenario), nothing here precludes adding one that shells out to the same CLI.
|
|
||||||
- **Raised by:** Ported from the claudian lineage.
|
|
||||||
|
|
||||||
### D-3: pql as supporter tool; clide wraps, never duplicates
|
|
||||||
- **Date:** 2026-04-20 (was ADR 0003; ported from the claudian lineage)
|
|
||||||
- **Decision:** Two complementary rules. **(1) Wrap, don't duplicate.** Clide never re-implements backlinks, ranking, frontmatter parsing, or wikilink resolution for query purposes. If a capability is missing in pql, it is added upstream in pql's repo and clide bumps the dependency. The only place clide contains pql logic is `lib/src/pql/` — pure shell-outs to the `pql` binary. **(2) pql is a clide subsystem when clide is present in the repo.** On load, clide writes its current state into `.pql/config.yaml` — no conditional sync. Clide only stomps keys it manages (starting with `ignore_files:` — see [D-4](#d-4-ignore-file-strategy)). Other pql config keys are left alone. Clide does **not** touch pql's index/cache data under `<repo>/.pql/` — that stays pql's private store.
|
|
||||||
- **Context:** [`pql`](https://github.com/postmeridiem/pql) is a pre-existing Go CLI that indexes a markdown-bearing directory tree into SQLite and exposes frontmatter, wikilinks, tags, headings, and bases through a query surface. Clide needs those capabilities for its Query panel, canvas drivers, graph view, and any feature that needs to know structure.
|
|
||||||
- **Rationale:** One source of truth for markdown semantics. Any new query capability the UI wants goes through a pql upstream PR, not a local workaround. Users never have to learn pql's config file to get consistent behaviour — clide manages it. The arrow clide → pql is never inverted: pql stays ignorant of its wrapper.
|
|
||||||
- **Cost:** Clide's `lib/src/pql/` package is deliberately thin. pql is also the **only** query engine — Obsidian-style inline "bases" are explicitly not supported; queries live at the repo level. In repos without clide, pql works standalone unaffected.
|
|
||||||
- **Raised by:** Ported from the claudian lineage. Load-bearing for [D-39](process.md#d-39-planning-tooling-lives-in-pql).
|
|
||||||
|
|
||||||
### D-4: Ignore file strategy
|
|
||||||
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
|
||||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
|
||||||
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
|
||||||
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
|
||||||
- **Cost:** Removing clide from a repo leaves pql working with vanilla defaults (clide's last-written `ignore_files:` stays until pql or the user rewrites it; worth reconsidering during uninstall design).
|
|
||||||
- **Raised by:** Ported from the claudian lineage.
|
|
||||||
|
|
||||||
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
|
||||||
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar))
|
|
||||||
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not.
|
|
||||||
- **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone.
|
|
||||||
- **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language.
|
|
||||||
- **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received.
|
|
||||||
- **Cost:** Rust remains an escape hatch, not a plan. If a Dart limit later forces a second native helper (file-watching at scale on macOS, a tree-sitter host, etc.), the precedent is: new native need → new supporter tool, peer of pql and `ptyc`. Never a second "core language." Supply-chain gates stay, shape changes — Go `govulncheck` removed, Dart advisories review + exact-pin stays, `ptyc` gets a "read the 150 lines" review checklist (see `make security`).
|
|
||||||
- **Raised by:** 2026-04-20 reassessment. See also the `ptyc` naming note in the original ADR (read as Project Terminal Controller / PTY+C / PTY+child).
|
|
||||||
|
|
||||||
### D-6: CLI and event surface contract
|
|
||||||
- **Date:** 2026-04-20 (was ADR 0006)
|
|
||||||
- **Decision:** The CLI is organised into **subsystems**. Each subsystem owns a noun, a set of verbs, and a set of events. The set is closed at any point in time (documented); growth is additive (new verbs, new events — never renaming existing ones without a version bump). Initial subsystems (by tier): `pane`, `tab`, `open`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`. Two umbrella entry points sit outside any subsystem: `clide tail --events [--filter <subsystem>[:<id>]]` and `clide status`. Command shape: `clide <subsystem> <verb> [<positional>...] [--flag ...] [-- argv...]`. Exit codes parity with pql (`0/1/2/3/4` + `64-78` sysexits reserved); diagnostic JSON on **stderr** on non-zero exit; stdout stays machine-parseable on success. Events are JSON objects, one per line, with `v`, `ts`, `type` (`<subsystem>.<verb_past|noun_changed>`), `subsystem`, `id`, and `payload`; binary payloads base64. Every state-changing command emits at least one event; read-only commands emit nothing. Replay buffer per subsystem (default depth 16) so late subscribers still see recent effects. Parity rule: every UI affordance has a matching CLI verb (or a follow-up task naming the verb); every CLI verb surfaces in the UI (or documents why it's Claude-only).
|
|
||||||
- **Context:** [D-1](#d-1-cli-first-not-mcp) established that Claude drives clide via a Bash CLI. That decided the *channel* — it did not define the *surface*. CLAUDE.md stated the rule colloquially ("every CLI subcommand has a UI affordance … if you add one side without the other, the feature is incomplete"); this record restates it as an implementable contract that satisfies user/Claude parity, daemon-as-authoritative-state, and pql-style ergonomics at once.
|
|
||||||
- **Rationale:** Surface is enumerable — adding a subsystem means adding a row and specifying verbs + events. Wire schema is versioned (`v: 1` starting point; compatibility breaks bump the major and land alongside a `pubspec.yaml` `schema_version:` bump — see [Q-5](questions-architecture.md#q-5-ipc-wire-format-stability)). Events are the only UI→app state channel; the Flutter app does not poll. Extensions inherit this — a Dart extension publishes a subsystem; the same registration pipeline exposes it to Claude via the CLI.
|
|
||||||
- **Cost:** Replay-buffer memory per subsystem (cheap — most emit seldom). Back-pressure on firehose streams ([Q-2](questions-architecture.md#q-2-back-pressure-on-event-streams)), authorisation granularity ([Q-1](questions-architecture.md#q-1-authorisation-granularity)), and event persistence ([Q-3](questions-architecture.md#q-3-event-persistence-audit-undo)) are all deferred until Tier 1 is in real use.
|
|
||||||
- **Raised by:** 2026-04-20 planning.
|
|
||||||
|
|
||||||
### D-43: Design handoff — adopt token palettes, reject Material wrapper
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** The claude.ai/design handoff (`docs/claude-design/`) delivers hi-fi mockups, interaction flows, a design system, and four theme palettes (clide, midnight, paper, terminal) as Dart files using `MaterialApp`/`ThemeData`. We adopt the colour tokens, layout annotations, typography direction, and syntax highlighting palettes. We reject the `MaterialApp` wrapper — tokens are translated into our existing YAML theme pipeline and `SurfaceTokens` (per [D-7](#d-7-app-root-is-bare-widgetsapp)). The design files stay in `docs/claude-design/` as reference; they are not runtime assets.
|
|
||||||
- **Rationale:** The design's value is in the palette + layout + component vocabulary, not in the delivery format. Material's `ThemeData` fights our bare-`WidgetsApp` + `CustomPaint` stance. Translating tokens preserves design intent without absorbing Material's widget opinions.
|
|
||||||
- **Cost:** Manual translation of four theme files into YAML. Ongoing: any design refresh needs the same translation pass.
|
|
||||||
- **Cross-reference:** [D-7](#d-7-app-root-is-bare-widgetsapp), [D-9](#d-9-three-tier-theme-pipeline), [R-12](rejected.md#r-12-materialapp-wrapper-from-design-handoff).
|
|
||||||
- **Raised by:** 2026-04-22 design handoff review.
|
|
||||||
|
|
||||||
### D-44: Four bundled themes — clide, midnight, paper, terminal
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Ship four bundled themes replacing the single summer-night preset. `clide` (cool near-black + periwinkle, default), `midnight` (VS Code-adjacent muted dark), `paper` (drafting-sheet light), `terminal` (near-black + amber). All share the same semantic token names. Source palettes in `docs/claude-design/themes/`; runtime YAML under `lib/kernel/src/theme/themes/`.
|
|
||||||
- **Rationale:** Summer-night was a placeholder carried from the legacy TUI. The design system delivers a coherent set of four that covers dark, muted-dark, light, and monochrome workflows.
|
|
||||||
- **Cost:** Summer-night users lose their theme (acceptable — it was dev-only). Four YAML files to maintain.
|
|
||||||
- **Cross-reference:** [D-43](#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper), [D-22](accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes).
|
|
||||||
- **Raised by:** 2026-04-22 design handoff review.
|
|
||||||
|
|
||||||
### D-45: Syntax highlighting tokens in the theme pipeline
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Add syntax-role colour tokens to `SurfaceTokens`: keyword, type, string, number, comment, method, punctuation. Each bundled theme defines these. The editor and diff views consume them; tree-sitter (when it lands per [Q-15](questions-architecture.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only)) maps grammar scopes to these tokens.
|
|
||||||
- **Rationale:** The design system ships syntax palettes per theme. Adding them now means the token surface is ready when syntax highlighting lands.
|
|
||||||
- **Cost:** Seven new fields on `SurfaceTokens`. Default resolution falls back to semantic roles (keyword → accent, comment → textMuted, etc.) so themes that don't declare syntax tokens still compile.
|
|
||||||
- **Raised by:** 2026-04-22 design handoff review.
|
|
||||||
|
|
||||||
### D-47: Interaction model — Claude-is-home layout
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** The prompt bar is pinned to a fixed Y-position in the middle column; every other surface makes room *around* Claude — never on top, never pushing the prompt off-Y. Three hard rules: (1) prompt bar Y-position is invariant across all states (open, collapsed, focus, editor, viewer); (2) the three bottom strips (left icon rail, app strip, right icon rail) align to one continuous horizontal line; (3) Claude is always the largest surface when present. The three-column layout from [D-11](#d-11-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset) is refined: left = overview (tickets, decisions, files, git, PRs), middle = Claude (+ optional editor above), right = context (viewer, pql graph, links, images). Both side panels have a bottom icon rail for section switching; keyboard: `⌥1–5` (left), context-type switcher (right).
|
|
||||||
- **Rationale:** "Claude is home" means the prompt never moves, regardless of what opens or closes around it. Every layout mutation respects this invariant. The three-column refinement assigns purpose to columns rather than leaving them generic.
|
|
||||||
- **Cost:** The prompt bar invariant constrains future layout presets — any preset that repositions Claude must explicitly break this rule. Editor mode (see [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap)) is the only case where another surface shares the middle column, and it opens *above* Claude rather than displacing it.
|
|
||||||
- **Cross-reference:** [D-11](#d-11-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-48: Chrome budget — no tabs, no breadcrumbs, keyboard-first
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Clide deletes classic IDE chrome: no buffer tabs, no breadcrumbs, no VS Code-style activity bar, no separate status bar row (merged into app strip). Total persistent chrome: 2 edge arrows (collapse toggles), 1 hover-only `⛶` glyph per panel (focus mode), 0 always-visible buttons beyond icon rails. `⌘P` overlay is the fuzzy finder — no layout shift. Keyboard is the primary interaction surface; icons are escape hatches. Files open individually; opening a second file closes the first (split on explicit command — deferred, see [Q-27](questions-architecture.md#q-27-two-editor-split)).
|
|
||||||
- **Rationale:** Every pixel of chrome that isn't Claude is a tax on the "Claude is home" principle. Tabs and breadcrumbs are navigation affordances for a multi-buffer editor; clide's editor is a secondary surface (viewer ↔ editor swap per [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap)), not a primary one. The fuzzy finder (`⌘P`) replaces all navigation chrome.
|
|
||||||
- **Cost:** Users accustomed to VS Code/IntelliJ tab workflows have no tabs to fall back on. Mitigated by `⌘P` fuzzy find being the universal navigation path. Resolves T-22 (multi-buffer editor tabs) as rejected in favour of this approach.
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-49: Editor mode — inline above Claude, viewer swap
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Editor invoked via `⌘E` on a file or `✎` icon in a viewer. Editor lifts *above* Claude in the middle column, occupying 30–40% of vertical space; Claude keeps the remainder; prompt bar Y unchanged. Close with `⌘W`. Draggable divider between editor and Claude. The viewer (`👁`) and editor (`✎`) are mutually exclusive for the same file — a `✎` click on a viewer promotes the file to editor in the middle column and snaps the right panel back to nav; a `👁` click on an editor demotes the file to viewer in the right panel and closes the editor. Different files can coexist (editor on `main.dart` + viewer on `README.md`). When editor is open on `.md`, the viewer auto-opens with live sync to editor content; no auto-viewer for non-renderable files (`.dart`, `.yaml`, etc.).
|
|
||||||
- **Rationale:** The editor is not a primary surface — it's a temporary intervention. Claude's prompt bar must never move ([D-47](#d-47-interaction-model-claude-is-home-layout)), so the editor opens above, not replacing. The viewer ↔ editor swap prevents two surfaces showing the same file simultaneously, which simplifies state management and avoids confusion about which surface is authoritative.
|
|
||||||
- **Cost:** Only one file in the editor at a time (no tabs per [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). Power users wanting two files side-by-side must wait for split (see [Q-27](questions-architecture.md#q-27-two-editor-split)).
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-50: Context auto-behavior — right panel reacts to Claude
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** The right panel responds to Claude's content references automatically: (1) right open + empty → panel holds footprint, stays empty; (2) right open + viewer loaded + Claude links `foo.md` → swap in, replaces current viewer; (3) right collapsed + Claude links `foo.md` → badge on spine ("2"), no layout shift; (4) editor open on `.md` → viewer auto-opens with live sync; (5) editor on non-renderable file → no auto-viewer.
|
|
||||||
- **Rationale:** Claude is the driver; the context panel is reactive. Auto-swapping when the panel is open reduces user clicks. Badging when collapsed respects the user's decision to collapse — no involuntary layout shifts.
|
|
||||||
- **Cost:** The auto-swap requires the daemon (or Claude integration) to emit structured content references, not just terminal text. This implies a lightweight parser or event that identifies file references in Claude's output — deferred to implementation.
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-51](#d-51-panel-collapse-12px-spine-with-badge).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-51: Panel collapse — 12px spine with badge
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** When collapsed, a panel becomes a 12px spine: vertically rotated label ("tickets" / "context"), no icon rail, `paper-2` background (slightly darker than main paper), border on inner edge only. Click anywhere on spine to expand. If a context badge is pending (e.g. Claude linked a file while collapsed): small filled dot with count at top of spine. Edge arrow on outer boundary toggles collapse; keyboard: `⌘⇧1` (left) / `⌘⇧3` (right). Expand restores prior size and section state.
|
|
||||||
- **Rationale:** Collapsed panels must not consume significant horizontal space (12px = 1 icon-width) but must remain discoverable and able to signal pending content. The badge-on-spine avoids involuntary expand while still communicating that something arrived.
|
|
||||||
- **Cost:** The spine replaces the current simple `setVisible(false)` toggle with a real collapsed-state widget. Collapse state must be persisted across sessions (see [D-53](#d-53-state-persistence-across-sessions)).
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-50](#d-50-context-auto-behavior-right-panel-reacts-to-claude).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-52: Focus mode — full-window takeover
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Focus mode entered via double-click on panel header, hover-visible `⛶` glyph in header, or `⌘.`. Active panel takes the full window; all others hidden. Header shows "Esc" hint. `Esc` restores the exact prior layout (collapse state, divider positions, active sections). Focus mode is per-panel, not per-tab.
|
|
||||||
- **Rationale:** When the user wants to concentrate on a single surface — Claude conversation, file tree, diff view — they shouldn't have to manually collapse both side panels. Focus mode is a single-action "maximise and restore" with no state loss.
|
|
||||||
- **Cost:** Must snapshot and restore full `LayoutArrangement` state on enter/exit. Interacts with responsive behaviour — focus mode at narrow widths should work identically.
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-53](#d-53-state-persistence-across-sessions).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-53: State persistence across sessions
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
|
|
||||||
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
|
|
||||||
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-51](#d-51-panel-collapse-12px-spine-with-badge), [D-52](#d-52-focus-mode-full-window-takeover).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-54: Keyboard map — canonical shortcuts
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** Canonical keyboard shortcuts (cross-platform, `⌘` = `Ctrl` on Linux): `⌘P` fuzzy find overlay; `⌘⇧1` / `⌘⇧3` collapse/expand left / right panel; `⌘1` / `⌘2` / `⌘3` focus left / middle / right panel; `⌘.` toggle focus mode on focused panel; `⌥1–⌥5` left-panel section switch (tickets, decisions, files, git, pr); `⌘E` open current file in editor; `⌘W` close editor / dismiss viewer; `Esc` exit focus mode / close fuzzy finder / dismiss viewer. Responsive breakpoints: ≥ 1600px splits relax toward 30%; 1200–1600px default (L 200px, R 220px, middle flex); < 1200px splits toward 40%, consider auto-collapse right; < 1000px deferred (see [Q-26](questions-architecture.md#q-26-small-screen-layout)).
|
|
||||||
- **Rationale:** These shortcuts follow the "keyboard is the primary surface" principle from [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first). The set is minimal and covers all layout operations. `⌘.` for focus mode follows VS Code precedent (quick-fix → general "do the thing").
|
|
||||||
- **Cost:** Some shortcuts may conflict with OS-level bindings on specific Linux desktops; the keybinding resolver ([D-17](extensions.md#d-17-panels-are-extension-shaped-from-day-one)) allows user override.
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-52](#d-52-focus-mode-full-window-takeover).
|
|
||||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
|
||||||
|
|
||||||
### D-55: Claude pane internal tabs for multi-session
|
|
||||||
- **Date:** 2026-04-23
|
|
||||||
- **Decision:** Multiple Claude sessions share the workspace as internal tabs inside the Claude pane header — not as workspace-level tabs (which would violate [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). The primary session tab has no close affordance (per [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)). Secondary session tabs show a close `×`. A small `+` button sits at the right end of the tab row to spawn a new secondary. Double-clicking empty space in the tab row also spawns a new secondary. When a secondary is closed, focus collapses to the most-recently-active remaining tab (primary or another secondary). The tab row is hidden when only the primary exists — it appears on first secondary spawn and disappears when the last secondary closes. Session names in the tab row use the tmux session name slug (readable path, per the session naming convention).
|
|
||||||
- **Amendment to [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed):** D-41 defined the lifecycle (primary persists, secondaries are ephemeral, close semantics) but left the multi-session UI unspecified. This record fills that gap. The `claude.new-secondary` command (already registered but not wired) is the spawn mechanism; the tab row is the UI surface.
|
|
||||||
- **Rationale:** The workspace is Claude's space ([D-47](#d-47-interaction-model-claude-is-home-layout)). Multiple Claude sessions are a Claude concern, not a workspace concern. Internal tabs keep the multiplicity contained — the workspace slot doesn't know how many sessions exist, it just renders the Claude pane. The hide-when-one rule keeps the common case (single primary) chrome-free.
|
|
||||||
- **Cost:** The Claude pane grows its own tab model (lightweight — just a list of session IDs + which is active). The `builtin.claude` extension owns this; no kernel changes needed.
|
|
||||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
|
||||||
- **Raised by:** 2026-04-23 interaction model refinement.
|
|
||||||
|
|
||||||
### D-56: Dissolve daemon process; Flutter app hosts IPC server
|
|
||||||
- **Date:** 2026-04-23
|
|
||||||
- **Decision:** The separate Dart daemon process (`clide --daemon`) and the two-package repo layout (`lib/` core + `app/` Flutter) are dissolved. The Flutter app moves to the repo root (one `pubspec.yaml`) and hosts the IPC server in-process. All subsystem handlers (pane, files, editor, git, pql) run inside the Flutter process. The `bin/clide.dart` AOT binary is removed. The CLI surface for Claude (`clide <command>`) becomes a thin C client — either a new peer of `ptyc` or a mode within `ptyc` itself — that connects to the app's unix socket, sends a JSON-lines request, prints the response, and exits. tmux owns session persistence (it already did per [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)); the daemon's PTY ownership was redundant.
|
|
||||||
- **Amendment to [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer):** D-5's "two execution modes of one Dart AOT binary" premise assumed the daemon needed to outlive the app to preserve PTY sessions. tmux already solves this — `tmux new-session -A` re-attaches regardless of which process originally spawned it. The daemon process added complexity (two packages, two build targets, IPC client/server split, process lifecycle management) without a benefit tmux doesn't already provide. D-5's other principles survive: Dart is the core language, `ptyc` is a C peer of pql, one language for the IDE proper.
|
|
||||||
- **Repo layout after dissolution:**
|
|
||||||
- `/pubspec.yaml` — single Flutter package (was `app/pubspec.yaml`)
|
|
||||||
- `/lib/` — all Dart code: kernel, extensions, widgets, subsystem handlers
|
|
||||||
- `/bin/` — empty or removed (CLI is a C binary now)
|
|
||||||
- `/test/` — all tests
|
|
||||||
- `/assets/` — fonts, themes, grammars, licenses
|
|
||||||
- `/ptyc/` — C PTY helper (unchanged)
|
|
||||||
- `/native/` — vendored native libs (libtree-sitter.so)
|
|
||||||
- `/decisions/`, `/docs/`, `/legacy/` — unchanged
|
|
||||||
- **Rationale:** One package means one `pubspec.yaml`, one `flutter analyze`, one `flutter test`, no `cd` gymnastics, no cross-package import barriers. The IPC server running in-process eliminates the daemon lifecycle (start, stop, reconnect, pid file). If the app crashes, tmux sessions survive; the app re-attaches on restart. The CLI client in C is ~100 lines (socket connect + JSON exchange) with the same contract as pql.
|
|
||||||
- **Cost:** If the Flutter app is not running, Claude's `clide` commands fail. In practice this is acceptable — the IDE being closed means the user isn't working. A future "headless mode" could start the Flutter engine without a window if needed.
|
|
||||||
- **Cross-reference:** [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-1](#d-1-cli-first-not-mcp) (CLI-first surface preserved via C client).
|
|
||||||
- **Raised by:** 2026-04-23 architectural simplification.
|
|
||||||
|
|
||||||
### D-57: Frameless custom chrome with per-column 24px hats
|
|
||||||
- **Date:** 2026-04-23
|
|
||||||
- **Decision:** The OS-native title bar is hidden. Each of the three columns wears its own 24px "hat" that serves as both a drag region and a host for window controls. Left hat: macOS traffic lights (Linux/Windows: plain drag). Center hat: `clide > branch` label, always present. Right hat: minimize/maximize/close glyph buttons on Linux/Windows (macOS: plain drag). Entire hat surface is draggable; buttons opt out of hit testing. When a column collapses to a 12px spine, its hat shrinks to a 12px drag cap — no buttons, still draggable. The center hat never collapses. Three `ChromeStyle` variants: `seam` (default desktop — full hats), `prompt` (center hat only — presentations/focus), `inline` (web/wasm — no hats, browser owns window controls). Persisted in settings as `app.chromeStyle`. Platform bridge via `MethodChannel('clide/window')` — custom GTK C and Cocoa Swift handlers, no third-party package.
|
|
||||||
- **Resolves:** [Q-6](questions-architecture.md#q-6-window-chrome-native-frame-vs-frameless-custom).
|
|
||||||
- **Rationale:** The GTK headerbar wastes 30+ vertical pixels and clashes with the custom theme. Per-column hats add zero net rows — they reuse the space each column header already occupied. Custom FFI avoids a `window_manager` dependency (D-31). The `ChromeStyle` enum keeps web builds clean and allows user override.
|
|
||||||
- **Cost:** ~150 lines C (GTK) + ~100 lines Swift (Cocoa) for the platform channel. Window controls become unreachable when their column collapses — mitigated by keyboard shortcuts (`⌘Q` to close, `⌘1`/`⌘3` to expand).
|
|
||||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout) (center hat always visible), [D-51](#d-51-panel-collapse-12px-spine-with-badge) (spine-cap behavior).
|
|
||||||
- **Raised by:** 2026-04-23 interaction model refinement.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Open Questions — Master Index
|
|
||||||
|
|
||||||
Open questions live in per-domain `questions-<domain>.md` files.
|
|
||||||
This index is a pointer and a place to record the most-load-bearing
|
|
||||||
open questions with one-line summaries.
|
|
||||||
|
|
||||||
## By domain
|
|
||||||
|
|
||||||
| File | Topics |
|
|
||||||
|------|--------|
|
|
||||||
| [questions-architecture.md](questions-architecture.md) | IPC, events, canvas, window chrome, macOS signing, pql absorption, ticket persistence, interaction model |
|
|
||||||
| [questions-extensions.md](questions-extensions.md) | Extension API shape, Lua runtime vendoring, manifest schema version |
|
|
||||||
| [questions-accessibility.md](questions-accessibility.md) | Web-mode a11y, i18n plurals/gender/dates |
|
|
||||||
| [questions-testing.md](questions-testing.md) | Coverage gates, screen-reader automation |
|
|
||||||
| [questions-process.md](questions-process.md) | Editor tab (LSP vs tree-sitter), icon set, theme hot-reload, kernel DB access, planning-tool location |
|
|
||||||
|
|
||||||
## Load-bearing questions (gate other work)
|
|
||||||
|
|
||||||
- **[Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate)** — Pql absorbs planning features vs clide absorbs pql vs separate CLI. Blocks the stopgap sunset and shapes the pql-side planning session.
|
|
||||||
- **[Q-22](questions-process.md#q-22-ticket-persistence-strategy)** — Ticket persistence: per-dev only / milestone-committed / markdown-mirrored. Shapes multi-contributor story.
|
|
||||||
- **[Q-5](questions-architecture.md#q-5-ipc-wire-format-stability)** — IPC wire-format stability and `schema_version:` in `pubspec.yaml`. Decide when the first real subcommand lands.
|
|
||||||
- **[Q-15](questions-process.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only)** — Editor tab: full LSP integration vs tree-sitter-only highlight. Decide during Tier 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Rejected Alternatives
|
|
||||||
|
|
||||||
Alternatives considered and rejected, with rationale preserved for
|
|
||||||
future reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### R-2: Go sidecar
|
|
||||||
- **Rejected:** 2026-04-20 (was ADR 0002; superseded by [D-5](architecture.md#d-5-dart-core-ptyc-peer))
|
|
||||||
- **Reason:** The ADR picked Go on two premises — (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so muscle memory transfers. Both broke on reassessment. The sidecar stripped of PTY is I/O-bound glue that `dart:io` covers cleanly (unix sockets, JSON-lines framing, process tables, shell-outs). The real axis was *separate process vs shared language*, not Go vs Rust, and separate-process is what matters (session persistence needs the daemon to outlive the app), not language. PTY is the one place Dart is genuinely weak — Dart's multi-threaded VM can't safely `fork()` — and that single constraint forces a native helper regardless, independent of whether the rest of the core is Dart. Once a small native helper is accepted, the question "does *everything else* need to be in that same native language" answers itself: no. Go sidecar directory dissolved; `ptyc` (C, PTY-only, pql-peer) is the surviving native supporter tool.
|
|
||||||
- **Cross-reference:** [D-5](architecture.md#d-5-dart-core-ptyc-peer)
|
|
||||||
|
|
||||||
### R-3: `MaterialApp` root
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** Dragged in Material theming, default icons, and platform chrome that fought the custom three-tier theme pipeline ([D-9](architecture.md#d-9-three-tier-theme-pipeline)). Every bundled theme had to override Material defaults to look like clide; the overrides were visible in widget tests as "why is this `ElevatedButton` colored this way."
|
|
||||||
- **Cross-reference:** [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp)
|
|
||||||
|
|
||||||
### R-4: Flutter `intl` + ARB codegen for i18n
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** ARB codegen is inflexible for plugin-contributed catalogs — every catalogue needs a codegen pass, every extension ships with pre-generated Dart, and runtime merging is fighting the tool. The fframe text-driven pattern reads JSON at runtime with no codegen, which fits extension-shipped catalogs cleanly.
|
|
||||||
- **Cross-reference:** [D-21](accessibility.md#d-21-i18n-is-a-tier-0-contract)
|
|
||||||
|
|
||||||
### R-5: Patrol test runner
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** Adds a dependency (violates [D-31](tooling.md#d-31-prefer-zero-deps-exact-pin)) for a capability we get from Playwright + Flutter's own semantics tree. Patrol's value proposition (native-gesture emulation) is less relevant on Linux desktop than on mobile.
|
|
||||||
- **Cross-reference:** [D-26](testing.md#d-26-web-driver-raw-playwright-plus-flutter-semantics)
|
|
||||||
|
|
||||||
### R-6: Nerd-font glyph icons
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** TUI hangover from the Python-era clide under `legacy/`. Not desktop-native; forces a font dependency; doesn't theme consistently. Clide uses custom icon primitives (Tier 6 revisits with proper icon-set design).
|
|
||||||
- **Cross-reference:** [Q-17](questions-process.md#q-17-icon-set-growth)
|
|
||||||
|
|
||||||
### R-7: `CupertinoApp` root
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** iOS-opinionated; wrong shell for a Linux-primary desktop IDE. Same theming-collision problem as [R-3](#r-3-materialapp-root).
|
|
||||||
- **Cross-reference:** [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp)
|
|
||||||
|
|
||||||
### R-8: Riverpod / Provider / BLoC for state
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** Violates [D-31](tooling.md#d-31-prefer-zero-deps-exact-pin). `ChangeNotifier` + `ListenableBuilder` ship in the SDK, fake trivially, and cover the state model we need. The ergonomic wins of Riverpod / Provider don't clear the "new dependency" bar at clide's scale.
|
|
||||||
- **Cross-reference:** [D-10](architecture.md#d-10-state-management-changenotifier)
|
|
||||||
|
|
||||||
### R-9: Port planning tooling into clide
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** Earlier in the planning session the assumption was "clide owns Dart subcommands for decisions + tickets." That breaks the day a contributor works in a terminal or in VS Code / JetBrains — they have no `clide` binary to run. Reversing: pql owns planning long-term (see [D-39](process.md#d-39-planning-tooling-lives-in-pql)); clide consumes via shell-out.
|
|
||||||
- **Cross-reference:** [D-39](process.md#d-39-planning-tooling-lives-in-pql)
|
|
||||||
|
|
||||||
### R-10: Python-script stopgap under `tooling/db/`
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** Location, not language. Settled-reach puts scripts at `tooling/db/` — copying that path here creates a script-pollution problem: every project using the pattern commits its own copy. The accepted Python port ([D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)) lives at `tools/scripts/plan`, clearly signalled as dev-tooling and time-limited.
|
|
||||||
- **Cross-reference:** [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
|
||||||
|
|
||||||
### R-11: Permanent stopgap
|
|
||||||
- **Rejected:** 2026-04-21
|
|
||||||
- **Reason:** If the Python port under `tools/scripts/plan` outlasts pql's feature parity, delete it. The deletion commit should be one changeset: remove `tools/scripts/plan`, remove its Makefile target (`decisions-validate` rewires to `pql decisions validate`), add a `CHANGELOG.md` entry under Removed, and verify `.pql/pql.db` still opens under the new `pql` binary.
|
|
||||||
- **Cross-reference:** [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
|
||||||
|
|
||||||
### R-12: MaterialApp wrapper from design handoff
|
|
||||||
- **Rejected:** 2026-04-22
|
|
||||||
- **Reason:** The design handoff delivers theme files as `MaterialApp`/`ThemeData` Dart classes. This is the delivery format of claude.ai/design, not a design intent. Adopting Material's widget system would contradict [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp) (bare WidgetsApp, no Material/Cupertino). We translate the palette tokens and syntax roles into our existing YAML + `SurfaceTokens` pipeline.
|
|
||||||
- **Cross-reference:** [D-43](architecture.md#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Tooling Decisions
|
|
||||||
|
|
||||||
Toolchain, supply chain, CI, ignore strategy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### D-31: Prefer-zero-deps, exact-pin
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** Default to writing code ourselves. Every third-party Dart dependency needs a paragraph of justification in the PR that adds it. What stays is exact-pinned in `pubspec.yaml` (no caret ranges), `pubspec.lock` is committed, and advisories are reviewed before every bump.
|
|
||||||
- **Rationale:** Supply-chain gate. Flutter SDK + Dart SDK give us most of what we need; the dependencies we keep are the ones we can't reasonably write (yaml parser, mocktail, alchemist). Exact-pin because caret ranges mean "the CVE bumps itself in silently."
|
|
||||||
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
|
||||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
|
||||||
|
|
||||||
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim.
|
|
||||||
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared.
|
|
||||||
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
|
||||||
- **Raised by:** 2026-04-21 planning.
|
|
||||||
|
|
||||||
### D-42: Dependencies documented in `licenses.yaml`
|
|
||||||
- **Date:** 2026-04-22
|
|
||||||
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
|
|
||||||
- **Rationale:** Complements [D-31](#d-31-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
|
|
||||||
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
|
|
||||||
- **Raised by:** 2026-04-22 planning (user-directed best practice).
|
|
||||||
|
|
||||||
### D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts
|
|
||||||
- **Date:** 2026-04-21
|
|
||||||
- **Decision:** `.gitignore` excludes `coverage.*` (the lcov output files from `flutter test --coverage`). Coverage-related scripts are named `ci/test_coverage.sh` (not `ci/coverage.sh`) to stay outside the pattern.
|
|
||||||
- **Rationale:** An earlier draft named the script `ci/coverage.sh` and it was silently git-ignored. Renaming the script is cheaper than narrowing the gitignore pattern (which risks re-introducing output churn).
|
|
||||||
- **Cost:** Script names have a convention to follow.
|
|
||||||
- **Raised by:** 2026-04-21 planning (caught during commit rehearsal).
|
|
||||||
|
|
||||||
### D-58: Format engines are adoptable dependencies
|
|
||||||
- **Date:** 2026-04-23
|
|
||||||
- **Decision:** The "own the rendering stack" guardrail applies to **UI chrome** — panels, tabs, panes, canvas, terminal, layout primitives. **Format engines** — packages that parse or render external file formats (SVG, markdown, HTML, terminal escape sequences, tree-sitter grammars) — are adoptable like any other dependency: vet, exact-pin, CVE-lock, document in `licenses.yaml`. They are not shortcuts for lazy coding; they are well-maintained renderers for formats we didn't invent. The distinction: if it renders *our* UI, we own it; if it renders *someone else's file format*, we adopt a parser/renderer and sandbox it.
|
|
||||||
- **Adopted under this rule:** `jovial_svg` (SVG renderer), `markdown` (MD parser; renderer is ours), `flutter_widget_from_html_core` (HTML renderer; sandboxed), `xterm` (terminal emulator), tree-sitter (syntax highlighting). Canvas (`CustomPaint` + `InteractiveViewer`) stays in-house — UI chrome, not a format engine.
|
|
||||||
- **Amendment to D-31 (prefer-zero-deps):** D-31's "prefer-zero-deps" still applies — every new dependency needs justification. This record clarifies that format engines clear the justification bar by default. The supply-chain gate (exact-pin, advisory review, `licenses.yaml`) still applies.
|
|
||||||
- **Rationale:** Reimplementing SVG, markdown, or VT100 parsing adds months of work for no fidelity gain. tree-sitter already set this precedent. The key is sandboxing: HTML rendering must whitelist tags/attributes; SVG must not execute scripts; markdown rendering goes through our own widget builder so we control the output.
|
|
||||||
- **Cost:** Each adopted engine adds transitive dependencies and supply-chain surface. Mitigated by exact-pinning and `make security`.
|
|
||||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
|
||||||
- **Raised by:** 2026-04-23 format engine evaluation.
|
|
||||||
|
|
||||||
### D-59: Bundled git via dugite-native
|
|
||||||
- **Date:** 2026-04-25
|
|
||||||
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
|
|
||||||
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
|
|
||||||
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
|
|
||||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates. GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
|
||||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
|
||||||
- **Raised by:** 2026-04-25 macOS sandbox investigation.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Codebase Cleanliness & Pattern Audit
|
||||||
|
|
||||||
|
**Date:** 2026-05-26
|
||||||
|
**Scope:** `lib/` (314 files, ~2.1MB) and `pubspec.yaml`. Skipped `legacy/`, `tests/`, generated files. Conventions grounded in [`CLAUDE.md`](CLAUDE.md) guardrails and the [`governance/decisions/`](governance/decisions/) D-records (architecture, extensions, testing, tooling, process).
|
||||||
|
|
||||||
|
## Baseline health
|
||||||
|
|
||||||
|
- `dart format --set-exit-if-changed`: **468/468 files clean.**
|
||||||
|
- `flutter analyze`: **3 warnings, all in a single WIP file** (`lib/builtin/claude/src/session_orchestrator.dart`), already in the modified-but-uncommitted set:
|
||||||
|
- L15: unused `dart:convert` import
|
||||||
|
- L16: unused `dart:io` import
|
||||||
|
- L29: unused private field `_resumeTailBytes`
|
||||||
|
- No `print(` / `debugPrint(` in `lib/`. No TODO / FIXME / HACK comments. No commented-out code blocks. No orphaned `.dart` files.
|
||||||
|
|
||||||
|
The repo is in unusually good baseline hygiene shape — the audit's interesting findings are structural, not janitorial.
|
||||||
|
|
||||||
|
## Guardrail / D-record compliance
|
||||||
|
|
||||||
|
| Decision | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **D-7** bare `WidgetsApp` | Pass | No `MaterialApp`/`CupertinoApp`/`Scaffold`/`ElevatedButton`. Material/Cupertino imports exist only in inlined xterm heritage under `lib/src/terminal/` and aren't instantiated. |
|
||||||
|
| **D-8** feature-first, barrels only | Pass | No cross-feature reach into another feature's `src/`. `lib/extension/src/` → `lib/kernel/src/` is the one cross-`src/` link (extension framework on platform foundation — legitimate). |
|
||||||
|
| **D-10** ChangeNotifier + ListenableBuilder | Pass | No provider/riverpod/bloc/get_it. Three `InheritedWidget` subclasses (`ScrollbarTheme`, `ClideKernel`, `ClideTheme` via `InheritedNotifier<ThemeController>`) are all justified scoped-context uses. |
|
||||||
|
| **D-31 / D-42** exact-pin + `licenses.yaml` | Pass | No caret ranges in `pubspec.yaml`. All 13 runtime deps + dev deps + native binaries documented. |
|
||||||
|
| **D-46** core frame vs shipped extensions | **Drift** | `editor`, `claude`, `claude-control`, `markdown`, `diff`, `git-ui`, `pql`, `canvas`, `graph`, `decisions`, `tickets`, `todos`, `problems` should be shipped extensions on a separate registration path. They still live in `lib/builtin/` alongside core frame builtins. Architectural intent documented but not enforced — migration deferred. |
|
||||||
|
| **D-56** single package, in-process IPC | Pass | No `bin/clide.dart`, no `app/`, no `sidecar/`. `lib/src/daemon/` is in-process dispatcher + handlers. |
|
||||||
|
| **D-1 / D-68** CLI primary, MCP secondary | Pass | `lib/src/ipc/server.dart` (unix socket) and `lib/src/ipc/mcp_server.dart` (HTTP+SSE) both wrap the same `DaemonDispatcher`. |
|
||||||
|
| **D-72** serial dispatch on main isolate | Pass | `server.dart:212` awaits `dispatcher.dispatch(req)` inside a per-client serial line handler. |
|
||||||
|
| **D-74** schema co-registered | Pass | `DaemonDispatcher` accepts `CommandSchema?` at registration (`dispatcher.dart:23`), validates pre-handler (L55–59). |
|
||||||
|
| **D-66** 95% coverage floor | Pass | `pubspec.yaml:26`: `coverage_floor: 95`. |
|
||||||
|
| **D-75 / D-77 / D-78** Claude coupling isolation | Pass | All `~/.claude/`, transcript JSONL, `claude` CLI invocations live behind `lib/builtin/claude/src/`. No leakage to other features. |
|
||||||
|
| **CLAUDE.md** in-house renderers | Pass | Markdown: pub.dev parser (per D-58) + custom renderer in `lib/widgets/src/clide_markdown.dart`. Canvas + graph in-house. |
|
||||||
|
| **Analyzer suppressions** | Pass | `// ignore`s are confined to xterm heritage code, FFI bindings (C naming), and lookup tables. All justified. |
|
||||||
|
|
||||||
|
## Findings worth acting on
|
||||||
|
|
||||||
|
### 1. Unused pubspec dependencies (D-31 violation in spirit)
|
||||||
|
|
||||||
|
- `flutter_widget_from_html_core: 0.17.2` — listed in `dependencies:`, imported nowhere. Mentioned in D-58 as adoptable, but no current consumer.
|
||||||
|
- `mocktail: 1.0.4` — listed in `dev_dependencies:`, imported nowhere. D-25 specifies it for IO mocks, but no tests use it today.
|
||||||
|
|
||||||
|
Per D-62 (Dependency removal process), removing requires the full 5-step PR. But carrying them violates D-31's "what stays is exact-pinned" intent — the spirit being "we keep only what we use."
|
||||||
|
|
||||||
|
### 2. D-46 architectural drift — known but uncodified
|
||||||
|
|
||||||
|
Thirteen "shipped extension" features still register through the core frame path. This is documented drift, not new — but it's the largest unresolved architectural debt. A dedicated `lib/extensions/` directory + a second registration tier is the prescribed fix.
|
||||||
|
|
||||||
|
### 3. Three hotspot files (>800 lines, mixed concerns)
|
||||||
|
|
||||||
|
| File | Lines | Shape |
|
||||||
|
|---|---|---|
|
||||||
|
| `lib/app.dart` | 1175 | 11+ State classes for sidebar/workspace/editor/context/status — split by region |
|
||||||
|
| `lib/src/terminal/src/core/escape/parser.dart` | 1139 | `EscapeParser` FSM, 1095-line class — handler logic could split by escape domain |
|
||||||
|
| `lib/src/terminal/src/terminal.dart` | 907 | `Terminal` class implementing 5 interfaces, 80+ methods spanning cursor/buffer/input/output |
|
||||||
|
|
||||||
|
The terminal pair is partially inlined heritage code (xterm.dart) so refactoring it competes with merge-friendliness; `app.dart` is yours to split freely.
|
||||||
|
|
||||||
|
### 4. Silent error swallowing in `lib/builtin/claude/src/claude_config.dart`
|
||||||
|
|
||||||
|
Seven `catch (_)` sites (L292, 301, 314, 394, 460, 489, 505) in config/skill loading. Config parsing failures vanish without log or UI signal. Per D-76 the service is supposed to "degrade gracefully" on parse miss — that's fine, but at minimum these should log to make schema drift visible (D-75 / D-78 explicitly call out that detection is the mitigation for CC-internals drift).
|
||||||
|
|
||||||
|
### 5. Long methods — theme resolvers and one claude config probe
|
||||||
|
|
||||||
|
| File:Line | Method | Lines |
|
||||||
|
|---|---|---|
|
||||||
|
| `lib/kernel/src/theme/resolver.dart:9` | `palette()` | 172 |
|
||||||
|
| `lib/kernel/src/theme/resolver.dart:11` | `semantic()` | 170 |
|
||||||
|
| `lib/kernel/src/theme/resolver.dart:13` | `surface()` | 168 |
|
||||||
|
| `lib/builtin/claude/src/claude_config.dart:306` | `_parseInitProbe()` | 178 |
|
||||||
|
|
||||||
|
Theme resolvers are dense token tables (data-shaped, not control-flow) — borderline but tolerable. The probe parser is the strongest splitting candidate.
|
||||||
|
|
||||||
|
### 6. Suspicious duplication
|
||||||
|
|
||||||
|
- `lib/builtin/tickets/src/tickets_view.dart` + `ticket_detail_view.dart` — 4+ near-identical `builder: (ctx, hovered, _) => ...` responsive button scaffolds. Extract a factory widget.
|
||||||
|
|
||||||
|
### 7. Magic strings worth a constant
|
||||||
|
|
||||||
|
- `'type': 'request' | 'response' | 'event'` recurs across `lib/src/ipc/envelope.dart` (L42, 79, 141). With two transports (socket + MCP) both speaking JSON envelopes, these belong as enum-or-const so a typo can't silently land.
|
||||||
|
- `lib/builtin/claude/src/transcript_reader.dart` (L477, 495, 498): `'user'`, `'assistant'`, `'permission-mode'` — strong candidates for an enum per the D-75 isolation principle (one place that knows the schema).
|
||||||
|
|
||||||
|
### 8. Coupling hub
|
||||||
|
|
||||||
|
`lib/main.dart` imports from 51 files — boot orchestrator, expected, but fragile. Splittable into `boot_kernel.dart` / `boot_ipc.dart` / `boot_ui.dart` if it grows further.
|
||||||
|
|
||||||
|
## What's notably *not* a problem
|
||||||
|
|
||||||
|
- No god-object utilities (`utils.dart`, `helpers.dart`, etc.) — feature-first discipline holds.
|
||||||
|
- No unchecked `as` casts — every cast is guarded by `is` or null-coalesce.
|
||||||
|
- No `dynamic` overuse outside legitimate JSON / Flutter API boundaries.
|
||||||
|
- Naming is uniformly Dart-idiomatic across 314 files.
|
||||||
|
- No deep control-flow nesting outside idiomatic `build()` trees.
|
||||||
|
|
||||||
|
## Recommended next moves
|
||||||
|
|
||||||
|
1. Land the three analyzer warnings in `session_orchestrator.dart` as part of the in-flight commit.
|
||||||
|
2. Decide on `flutter_widget_from_html_core` and `mocktail` — either wire them in or remove them via the D-62 process.
|
||||||
|
3. Open a tracking ticket for the D-46 migration if one doesn't exist; this is the only meaningful drift.
|
||||||
|
4. Split `app.dart` by layout region — lowest-risk, highest-readability win.
|
||||||
|
5. Add logging (not exception propagation) to the `claude_config.dart` silent catches so schema drift surfaces during real use.
|
||||||
|
6. Extract envelope type strings to constants/enum in `lib/src/ipc/envelope.dart` before the second transport (MCP) accumulates more divergence.
|
||||||
|
|
||||||
|
## Overall assessment
|
||||||
|
|
||||||
|
This is a tidy codebase. The audit found one architectural drift (D-46, already documented), three localized hotspots, two stale pubspec entries, and a handful of cosmetic improvements. Nothing systemic.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# clide — Architecture
|
||||||
|
|
||||||
|
Current as of 2026-05-17. Tracks the code on `main`. For the original
|
||||||
|
design plan (much of it now superseded), see
|
||||||
|
[`docs/initial-plan.md`](initial-plan.md). For decisions, see
|
||||||
|
[`governance/decisions/`](../governance/decisions/).
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
clide is a **single Flutter package at the repo root**. There is no
|
||||||
|
sidecar process, no separate daemon, no Go binary. One `flutter run`
|
||||||
|
boots the whole IDE.
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter desktop app (lib/main.dart)
|
||||||
|
├── kernel services (lib/kernel/) — theme, i18n, settings, panels,
|
||||||
|
│ commands, focus, scheduler, …
|
||||||
|
├── core subsystems (lib/src/) — ipc, daemon dispatch, panes,
|
||||||
|
│ │ pty, editor, files, git, pql
|
||||||
|
│ └── PTY via Dart FFI posix_openpt + posix_spawn (T-96)
|
||||||
|
├── widget primitives (lib/widgets/) — ClideButton, ClideText, … no
|
||||||
|
│ Material/Cupertino
|
||||||
|
├── built-in extensions (lib/builtin/) — claude, editor, files, git, terminal,
|
||||||
|
│ welcome, … each a ClideExtension
|
||||||
|
└── extension framework (lib/extension/) — contract + dependency-gated activation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process model
|
||||||
|
|
||||||
|
One OS process. The Flutter app hosts:
|
||||||
|
|
||||||
|
- the IPC dispatcher (`DaemonDispatcher` in `lib/src/daemon/`),
|
||||||
|
- every subsystem handler (pane/files/editor/git/pql),
|
||||||
|
- the extension manager and all built-in extensions.
|
||||||
|
|
||||||
|
`tmux` is the only external long-lived process — it owns Claude
|
||||||
|
session persistence so panes survive app restarts (D-41). The app
|
||||||
|
re-attaches via `tmux new-session -A` on boot.
|
||||||
|
|
||||||
|
PTYs are spawned natively from Dart. `lib/src/pty/native_pty.dart`
|
||||||
|
calls `posix_openpt()` + `posix_spawn()` via FFI; the child inherits
|
||||||
|
the slave PTY as stdin/stdout/stderr. No C helper binary.
|
||||||
|
|
||||||
|
### Native dependencies
|
||||||
|
|
||||||
|
Vendored under [`native/`](../native/) with per-platform subdirectories
|
||||||
|
(`linux-x64/` today). Currently:
|
||||||
|
|
||||||
|
- `libtree-sitter.so` — wasmtime-embedded tree-sitter for syntax
|
||||||
|
highlighting. Loaded via `dart:ffi`. See
|
||||||
|
`lib/kernel/src/syntax/tree_sitter_ffi.dart`.
|
||||||
|
|
||||||
|
Each entry is listed in [`assets/licenses.yaml`](../assets/licenses.yaml)
|
||||||
|
with version + SHA expectation (D-42).
|
||||||
|
|
||||||
|
### External tools
|
||||||
|
|
||||||
|
clide shells out to two binaries at runtime:
|
||||||
|
|
||||||
|
- **`git`** — vendored as `dugite-native` if present at the install
|
||||||
|
directory; otherwise `PATH` `git`. Resolution happens at
|
||||||
|
`lib/kernel/src/toolchain_paths.dart`. **Never resolves against the
|
||||||
|
open workspace** (T-98).
|
||||||
|
- **`pql`** — the [pql](https://github.com/postmeridiem/pql) project
|
||||||
|
query language; supporter tool, wrapped in `lib/src/pql/`. Clide
|
||||||
|
never re-implements pql features (D-3).
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
### Claude-facing — `clide` CLI
|
||||||
|
|
||||||
|
Per D-1 and D-6, Claude talks to clide exclusively via Bash. Every
|
||||||
|
state-changing command emits one or more events on a long-lived
|
||||||
|
event stream; every UI affordance has a matching CLI verb. See D-6
|
||||||
|
for the subsystem/verb/event contract.
|
||||||
|
|
||||||
|
> **Caveat (2026-05):** the Unix-socket server that exposes the
|
||||||
|
> dispatcher to a thin `clide` C client is currently unimplemented.
|
||||||
|
> Today's working path is in-process direct dispatch. See **T-99**
|
||||||
|
> (IPC server implementation) and **D-68** (dual integration surface
|
||||||
|
> — Bash CLI primary, MCP secondary).
|
||||||
|
|
||||||
|
### User-facing — Flutter desktop
|
||||||
|
|
||||||
|
Three-column layout (sidebar / workspace / context) with collapsible
|
||||||
|
panels, a custom title bar, and per-panel "hats" for branding +
|
||||||
|
window controls. The interaction model is documented in D-47 and
|
||||||
|
neighboring decisions.
|
||||||
|
|
||||||
|
## Subsystems at a glance
|
||||||
|
|
||||||
|
| Subsystem | Location | Owns |
|
||||||
|
|---|---|---|
|
||||||
|
| IPC envelope + dispatcher | `lib/src/ipc/`, `lib/src/daemon/` | request/response framing, command registration, broadcast events |
|
||||||
|
| Pane registry | `lib/src/panes/` | spawn/list/write/resize/close, event emission |
|
||||||
|
| PTY | `lib/src/pty/` | `posix_openpt` + `posix_spawn`, reader isolate, signal forwarding |
|
||||||
|
| Editor | `lib/src/editor/` | buffer registry, open/save/setContent |
|
||||||
|
| Files | `lib/src/files/` | ls / read / watch, path-safety containment check |
|
||||||
|
| Git | `lib/src/git/` | client (no shell), status/diff/operations parsing |
|
||||||
|
| Pql wrapper | `lib/src/pql/` | shell-out only; never re-implements pql |
|
||||||
|
| Kernel services | `lib/kernel/src/` | theme, i18n, settings, panels, commands, focus, syntax |
|
||||||
|
| Extensions | `lib/extension/`, `lib/builtin/` | dependency-gated activation, contribution points |
|
||||||
|
|
||||||
|
## Build + test
|
||||||
|
|
||||||
|
```
|
||||||
|
make hooks && flutter pub get # one-time setup
|
||||||
|
make run # launch app
|
||||||
|
make test # analyze + format + unit + widget + golden
|
||||||
|
make test-core # IPC / PTY / git / pane registry
|
||||||
|
make test-a11y # accessibility contract
|
||||||
|
make test-integration # real-app boot tests
|
||||||
|
make push-check # the full pre-push gate
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-push gate enforces a 95% line-coverage floor (D-66), a
|
||||||
|
40-word soft / 60-word hard CHANGELOG bullet cap, all unit/widget
|
||||||
|
tests, and the a11y contract.
|
||||||
|
|
||||||
|
## Governance
|
||||||
|
|
||||||
|
All architectural choices live in
|
||||||
|
[`governance/decisions/<domain>.md`](../governance/decisions/) as
|
||||||
|
`D-NNN` records, open questions as `Q-NNN`, rejected alternatives as
|
||||||
|
`R-NNN`. Claim new IDs via `pql decisions claim D <domain> "title"`.
|
||||||
|
The governance index lists everything: [`governance/README.md`](../governance/README.md).
|
||||||
|
|
||||||
|
The pql ticket backlog tracks in-flight work; `pql ticket board
|
||||||
|
--pretty` for the live view.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# PTY + IPC error-handling audit
|
||||||
|
|
||||||
|
Date: 2026-05-05
|
||||||
|
Ticket: T-18
|
||||||
|
Decision ref: D-5
|
||||||
|
|
||||||
|
Punch list of error-handling issues in `lib/src/pty/`, `lib/src/ipc/`,
|
||||||
|
and `lib/src/daemon/`. Severity-ranked. Each item references the
|
||||||
|
follow-up ticket where the fix lands.
|
||||||
|
|
||||||
|
## Critical — silent failures, leaks, races
|
||||||
|
|
||||||
|
1. **`lib/src/pty/native_pty.dart:155-158`** — `forkpty()` failure
|
||||||
|
throws `StateError('forkpty() failed')` with no errno. Caller
|
||||||
|
can't distinguish ENOMEM/EAGAIN/ENOENT-of-/dev/ptmx. Capture
|
||||||
|
errno before `_freeAll` (which may trample it) and surface via
|
||||||
|
`PtyException`. → T-75
|
||||||
|
|
||||||
|
2. **`lib/src/pty/native_pty.dart:160-165`** — Child process: `chdir`
|
||||||
|
and `execve` returns are ignored. If `execve` returns (i.e.
|
||||||
|
fails), we fall through to `_exit(1)` with no diagnostic. Write
|
||||||
|
a one-line error envelope to fd 1 before exiting so the parent's
|
||||||
|
reader sees "exec failed: ENOENT" instead of immediate EOF. → T-75
|
||||||
|
|
||||||
|
3. **`lib/src/pty/native_pty.dart:244-251`** — `write()` ignores
|
||||||
|
`_nativeWrite` return. Short writes silently drop bytes; -1/EPIPE
|
||||||
|
reported as successful "wrote -1". Loop until full length is
|
||||||
|
written or surface errno on negative returns. → T-75
|
||||||
|
|
||||||
|
4. **`lib/src/pty/native_pty.dart:259-262`** — `resize()` ignores
|
||||||
|
`_ioctl` and `_nativeKill` return values. EBADF on a half-closed
|
||||||
|
fd silently no-ops. Set `_dead = true` on EBADF. → T-75
|
||||||
|
|
||||||
|
5. **`lib/src/pty/native_pty.dart:198-210`** — Race: `_spawnReader`
|
||||||
|
is `async` but `NativePty.start` returns immediately. `close()`
|
||||||
|
racing with isolate spawn can leave the isolate orphaned. Make
|
||||||
|
`start` await reader spawn or track the spawn-future. → T-76
|
||||||
|
|
||||||
|
6. **`lib/src/pty/native_pty.dart:280-290`** — `close()` sets
|
||||||
|
`_dead = true` *before* `_nativeClose(_fd)`, but the reader
|
||||||
|
isolate continues polling on `_fd`. If a new fd reuses that
|
||||||
|
number, the reader's `poll` may briefly target the wrong file.
|
||||||
|
Send shutdown signal via SendPort or self-pipe before closing. → T-76
|
||||||
|
|
||||||
|
7. **`lib/src/pty/session.dart:135-153`** — Resource leak: if
|
||||||
|
`_recvFdAsync`, `setWinsize`, `proc.stdout.first.timeout`, or
|
||||||
|
`_extractPid` throws, the spawned ptyc Process and (in some
|
||||||
|
cases) the received `masterFd` leak. Only line 151 closes
|
||||||
|
`masterFd`. Wrap post-spawn block in try/catch that kills `proc`,
|
||||||
|
closes `masterFd`, and rethrows. → T-76
|
||||||
|
|
||||||
|
8. **`lib/src/pty/session.dart:240`** — `_recvFdAsync`: if
|
||||||
|
`Isolate.spawn` itself throws, `port` is leaked. Wrap in
|
||||||
|
try/catch. → T-76
|
||||||
|
|
||||||
|
9. **`lib/src/pty/session.dart:165-176`** — `write()` returns raw
|
||||||
|
`libc.write` result without checking < 0 / errno or looping for
|
||||||
|
short writes. Same as #3. → T-75
|
||||||
|
|
||||||
|
10. **`lib/src/pty/session.dart:271-275`** — `Isolate.spawn(...).then(...)`
|
||||||
|
is fire-and-forget. If spawn fails, the error is silently
|
||||||
|
swallowed and `_readerIsolate` remains null forever. Add
|
||||||
|
`.catchError` or await it. → T-76
|
||||||
|
|
||||||
|
11. **`lib/src/ipc/server.dart:30-39`** — `broadcast()` `try/catch (_)`
|
||||||
|
swallows write errors with no logging. At least log the kind. → T-77
|
||||||
|
|
||||||
|
12. **`lib/src/ipc/server.dart:107`** — `client.writeln(resp.encode())`
|
||||||
|
is not awaited and not guarded. If client disconnected mid-dispatch,
|
||||||
|
this throws asynchronously with no `onError` handler. Wrap in
|
||||||
|
try/catch and remove the client from `_clients`. → T-77
|
||||||
|
|
||||||
|
13. **`lib/src/ipc/server.dart:83-108`** — `_handleLine` runs
|
||||||
|
`await dispatch(msg)` with no per-request timeout. A misbehaving
|
||||||
|
handler blocks the connection's read pipeline indefinitely. → T-77
|
||||||
|
|
||||||
|
14. **`lib/src/ipc/server.dart:46-50`** — Stale-socket retry deletes
|
||||||
|
the socket file unconditionally on `SocketException`. If two
|
||||||
|
daemon instances race to start, the second rips the first's live
|
||||||
|
socket out from under it. Try `connect()` first; refuse if a
|
||||||
|
live daemon answers. → T-77
|
||||||
|
|
||||||
|
## High — degraded UX / debugging
|
||||||
|
|
||||||
|
15. **`lib/src/daemon/pane_commands.dart:87-96`** — `_spawn`
|
||||||
|
catch-all flattens every failure into `tool_error: pane.spawn
|
||||||
|
failed: <toString>`. "binary not found", "permission denied",
|
||||||
|
"out of pty fds" all look the same. Map `PtyException.errno`
|
||||||
|
(ENOENT/EACCES/EMFILE) to distinct hints/codes. → T-79
|
||||||
|
|
||||||
|
16. **`lib/src/daemon/editor_commands.dart:67-76`** — Same pattern;
|
||||||
|
`editor.open` catch-all loses FileSystemException distinctions
|
||||||
|
(ENOENT vs EACCES vs EISDIR). → T-79
|
||||||
|
|
||||||
|
17. **`lib/src/daemon/files_commands.dart:78`** — `file.readAsStringSync()`
|
||||||
|
is unguarded; UTF-8 errors, permission errors, races with deletion
|
||||||
|
turn into a 500-style dispatch error instead of a clean
|
||||||
|
`IpcResponse.err`. Wrap in try/catch. → T-81
|
||||||
|
|
||||||
|
18. **`lib/src/daemon/files_commands.dart:74`** — Path is concatenated
|
||||||
|
with `/` and never validated. `path: "../../../etc/passwd"`
|
||||||
|
traverses out of `files.root`. Resolve and verify the resulting
|
||||||
|
path stays under `root.absolute.path`. → T-78 (security)
|
||||||
|
|
||||||
|
19. **`lib/src/pty/session.dart:201-234`** — `close()` distinguishes
|
||||||
|
EOF/EBADF/EIO only in comments. The 500ms timeout is silent
|
||||||
|
(`onTimeout: () {}`). Log the timeout so we know when SIGKILL
|
||||||
|
was actually needed. → T-81
|
||||||
|
|
||||||
|
20. **`lib/src/pty/session.dart:390-394`** — Reader isolate treats
|
||||||
|
any negative read return that isn't EINTR as EOF — including
|
||||||
|
transient EAGAIN or recoverable EIO. Inspect errno and log
|
||||||
|
non-EBADF/EIO/0 cases. → T-81
|
||||||
|
|
||||||
|
21. **`lib/src/pty/ffi/scm_rights.dart:115-116`** — Returned cmsg-data
|
||||||
|
fd is read without sanity-checking against `msgControllen`. A
|
||||||
|
malformed peer that sends only a partial cmsg could let us read
|
||||||
|
garbage as an fd. Verify `dataOffset + 4 <= msgControllen`
|
||||||
|
before deref. → T-81
|
||||||
|
|
||||||
|
22. **`lib/src/ipc/server.dart:41-56`** — `start()` logs to
|
||||||
|
`stderr.writeln` but the rest of the daemon uses no logger. In
|
||||||
|
the Flutter-host process stderr is often consumed by the engine.
|
||||||
|
Standardize on a logger. → T-80
|
||||||
|
|
||||||
|
## Medium — cleanliness
|
||||||
|
|
||||||
|
23. **`lib/src/pty/session.dart:390`, `native_pty.dart:262, 285`** —
|
||||||
|
Magic errno/signal numbers (`4=EINTR`, `9=SIGKILL`, `28=SIGWINCH`,
|
||||||
|
`_kSighup=1`). Pull into named constants. → T-80
|
||||||
|
|
||||||
|
24. **`lib/src/pty/ffi/libc.dart:232-245`** — `errno` getter does a
|
||||||
|
`lookupFunction` on every access (catching ArgumentError every
|
||||||
|
call on macOS). Cache the resolved function pointer. → T-80
|
||||||
|
|
||||||
|
25. **`lib/src/daemon/git_commands.dart:283`** — `_gitError` always
|
||||||
|
reports `tool_error`. A `git push` rejection or merge conflict is
|
||||||
|
user-actionable, not a tool failure; could map to
|
||||||
|
`IpcExitCode.conflict` when stderr matches known patterns. → T-81
|
||||||
|
|
||||||
|
26. **`lib/src/ipc/server.dart:97`** — Dispatch error shows
|
||||||
|
`dispatch failed: $e` (full exception toString). Trim and add
|
||||||
|
the request `cmd` for log correlation. → T-80
|
||||||
|
|
||||||
|
27. **`lib/src/daemon/pane_commands.dart:136`** — `registry.write(id, bytes)`
|
||||||
|
return value `n` is shown to caller, but if `n == -1` (write failed)
|
||||||
|
we still respond `ok`. Distinguish. → T-81
|
||||||
|
|
||||||
|
28. **`lib/src/ipc/envelope.dart:88-94`** — `IpcResponse.fromJson`
|
||||||
|
throws `TypeError` if `ok=false` but `error` is missing. No
|
||||||
|
graceful degradation for a malformed peer response. → T-81
|
||||||
|
|
||||||
|
29. **`lib/src/pty/native_pty.dart:111-119`** — PATH resolution
|
||||||
|
silently uses the first existing match without checking `X_OK`.
|
||||||
|
A non-executable file shadows a valid binary further along PATH. → T-81
|
||||||
@@ -1,4 +1,25 @@
|
|||||||
# clide · design handoff
|
# clide · design handoff (superseded reference)
|
||||||
|
|
||||||
|
> **Status (2026-05-06):** Reference-only. The implementation has
|
||||||
|
> moved past these mockups. The canonical wireframe set now lives at
|
||||||
|
> [`docs/wireframes/`](../wireframes/), generated from the actual
|
||||||
|
> implementation via the `frame0-wireframe` skill.
|
||||||
|
>
|
||||||
|
> Update wireframes there, not here.
|
||||||
|
>
|
||||||
|
> **Why kept:** the design tokens under `tokens/` and `themes/` still
|
||||||
|
> feed the runtime themes (per [D-43](../../governance/decisions/architecture.md#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
||||||
|
> / [D-44](../../governance/decisions/architecture.md#d-44-four-bundled-themes-clide-midnight-paper-terminal)).
|
||||||
|
> The HTMLs and PNGs are kept for historical context.
|
||||||
|
>
|
||||||
|
> **What changed since:** welcome screen has logo-with-wordmark and a
|
||||||
|
> Tips card spanning both columns; status line with theme switcher
|
||||||
|
> lives at the bottom right; Claude pane runs in fullscreen mode
|
||||||
|
> (`CLAUDE_CODE_NO_FLICKER=1`) so the input box is pinned by Claude
|
||||||
|
> Code itself; tmux uses an isolated `-L clide` socket with bundled
|
||||||
|
> config; sidebar layout follows D-47's "Claude is home" model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Bundle for importing into the clide repo and driving further work with Claude Code.
|
Bundle for importing into the clide repo and driving further work with Claude Code.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Multitab pane — design
|
||||||
|
|
||||||
|
Ticket: T-83
|
||||||
|
Drives: T-24 (secondary Claude pane UI wiring)
|
||||||
|
Date: 2026-05-06
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Some panes need to host multiple, dynamically-spawned views of the
|
||||||
|
same kind. The first concrete case is the Claude pane: per
|
||||||
|
[D-41](../../governance/decisions/architecture.md#d-41-claude-panes-one-primary-per-repo-tmux-backed),
|
||||||
|
each repo has exactly one **primary** Claude pane plus zero or more
|
||||||
|
**secondary** panes spawned at runtime. The user needs a way to:
|
||||||
|
|
||||||
|
- See which Claude sessions are open
|
||||||
|
- Switch between them
|
||||||
|
- Spawn a new secondary
|
||||||
|
- Close a secondary (primary has no close affordance)
|
||||||
|
|
||||||
|
The kernel's existing `TabContribution` system addresses a different
|
||||||
|
need — it lets extensions statically declare which widget shows up in
|
||||||
|
which **panel slot** (sidebar, workspace, context). It does not
|
||||||
|
support dynamic tab instances *within* a single contribution.
|
||||||
|
|
||||||
|
This design fills that gap with a reusable widget, so future panes
|
||||||
|
that need the same shape (potentially the editor — see D-48 — or
|
||||||
|
diff/preview surfaces) can adopt it without reinventing tab strips.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing `TabContribution`. Slot-host tabs are static-by-design;
|
||||||
|
this widget is for inside-a-tab dynamism.
|
||||||
|
- Window-level tab management (browser-style "tear off into a window").
|
||||||
|
- Editor multi-buffer tabs. [D-48](../../governance/decisions/architecture.md#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)
|
||||||
|
rejected those; revisiting is a separate decision.
|
||||||
|
|
||||||
|
## API sketch
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MultitabPane<T> extends StatefulWidget {
|
||||||
|
const MultitabPane({
|
||||||
|
required this.controller,
|
||||||
|
required this.tabBuilder,
|
||||||
|
required this.bodyBuilder,
|
||||||
|
this.onCloseRequested,
|
||||||
|
this.onAddRequested,
|
||||||
|
this.allowReorder = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MultitabController<T> controller;
|
||||||
|
final Widget Function(BuildContext, MultitabEntry<T>) tabBuilder;
|
||||||
|
final Widget Function(BuildContext, MultitabEntry<T>) bodyBuilder;
|
||||||
|
final void Function(MultitabEntry<T> entry)? onCloseRequested;
|
||||||
|
final void Function()? onAddRequested;
|
||||||
|
final bool allowReorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultitabEntry<T> {
|
||||||
|
final String id; // stable identity (e.g. "claude.primary")
|
||||||
|
final String title; // display label
|
||||||
|
final bool closeable; // primary tabs set this false
|
||||||
|
final bool reorderable; // primary often pinned to position 0
|
||||||
|
final T payload; // domain object the bodyBuilder renders
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultitabController<T> extends ChangeNotifier {
|
||||||
|
List<MultitabEntry<T>> get entries;
|
||||||
|
MultitabEntry<T>? get active;
|
||||||
|
|
||||||
|
void add(MultitabEntry<T> entry, {bool activate = true});
|
||||||
|
void remove(String id);
|
||||||
|
void activate(String id);
|
||||||
|
void reorder(String id, int newIndex);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The widget is a thin shell:
|
||||||
|
- Renders the tab strip via `ClideTabBar` (or a reorderable variant)
|
||||||
|
- Calls `bodyBuilder(active)` for the visible content
|
||||||
|
- Routes user gestures to controller methods or callbacks
|
||||||
|
- Emits `onCloseRequested` / `onAddRequested` so the host decides
|
||||||
|
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
|
||||||
|
doesn't just append a UI tab)
|
||||||
|
|
||||||
|
The host owns the controller and the payload type. The widget never
|
||||||
|
touches PTY, IPC, or Claude session naming.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
The tab strip lives at the top of the pane chrome. Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ [primary] [secondary 1] [secondary 2] [+] │
|
||||||
|
├──────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ active tab body │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Active tab: filled background, bright text
|
||||||
|
- Inactive: muted background, muted text
|
||||||
|
- Close glyph (×) appears on hover for `closeable` tabs
|
||||||
|
- `+` button at the end if `onAddRequested` is set
|
||||||
|
- Drag-to-reorder respects `reorderable`; non-reorderable tabs
|
||||||
|
(primary) are pinned to position 0 and other tabs cannot be
|
||||||
|
dropped before them
|
||||||
|
|
||||||
|
## Interaction
|
||||||
|
|
||||||
|
- **Click a tab** → activate
|
||||||
|
- **Click ×** → call `onCloseRequested(entry)`; host decides whether
|
||||||
|
to confirm, kill the underlying session, etc.
|
||||||
|
- **Drag-and-drop** → call `controller.reorder(id, newIndex)` after
|
||||||
|
the gesture completes; controller enforces pinned positions
|
||||||
|
- **Click +** → call `onAddRequested()`; host creates the new entry
|
||||||
|
and adds it via `controller.add(...)`
|
||||||
|
- **Keyboard**: `⌘1`–`⌘9` jump to tab N; `⌘W` close active (skipped
|
||||||
|
for non-closeable); `⌘⇧[` / `⌘⇧]` cycle prev/next
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Out of scope for the widget. Hosts that want to persist tab order or
|
||||||
|
which tabs were open across sessions read/write through their own
|
||||||
|
settings layer and seed the controller on init.
|
||||||
|
|
||||||
|
## Claude pane integration (T-24)
|
||||||
|
|
||||||
|
```
|
||||||
|
ClaudePane (host)
|
||||||
|
└── MultitabPane<ClaudeSessionRef>(
|
||||||
|
controller: claudeTabsController,
|
||||||
|
tabBuilder: (ctx, e) => Text(e.title),
|
||||||
|
bodyBuilder: (ctx, e) => ClaudePaneBody(session: e.payload),
|
||||||
|
onAddRequested: () => kernel.claude.spawnSecondary(),
|
||||||
|
onCloseRequested: (e) => kernel.claude.closeSecondary(e.payload),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ClaudeSessionRef` carries the tmux session name + isPrimary. The
|
||||||
|
controller is seeded with `[primary]` on boot; secondaries get
|
||||||
|
appended as the user clicks `+`. Closing a secondary triggers
|
||||||
|
`pane.close` IPC and removes the entry; closing the primary is not
|
||||||
|
exposed (`closeable: false`).
|
||||||
|
|
||||||
|
## What ships in this ticket
|
||||||
|
|
||||||
|
T-83 delivers:
|
||||||
|
1. `MultitabPane` widget + `MultitabController` + `MultitabEntry`
|
||||||
|
under `lib/widgets/src/`
|
||||||
|
2. Unit tests for controller invariants (pinned positions, active
|
||||||
|
selection survives close, reorder bounds)
|
||||||
|
3. Widget tests for the strip (selection, close hover, add button,
|
||||||
|
drag-reorder)
|
||||||
|
4. This design doc
|
||||||
|
|
||||||
|
T-24 picks up after and wires the Claude pane to it.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Q: Where does keyboard handling live?** Host or widget?
|
||||||
|
Recommendation: widget owns `⌘W` / `⌘1`–`⌘9` / cycle; host wires
|
||||||
|
them via the existing kernel commands surface. Avoids each host
|
||||||
|
reinventing the same shortcuts.
|
||||||
|
|
||||||
|
**Nesting caveat:** the widget composes (a Claude tab can host
|
||||||
|
its own `MultitabPane<EditorBuffer>` etc.). Shortcut handling
|
||||||
|
must be scoped to the focus subtree, not registered globally —
|
||||||
|
otherwise the outermost pane consumes `⌘W` even when the user
|
||||||
|
is typing in a nested tab. Implementation: wrap shortcuts in a
|
||||||
|
`Shortcuts` / `Actions` widget inside the pane's `Focus` scope
|
||||||
|
so the innermost focused pane wins via Flutter's normal
|
||||||
|
shortcut-resolution chain.
|
||||||
|
|
||||||
|
- **Q: Tab overflow** when many secondaries open? Recommendation:
|
||||||
|
start with horizontal scroll; revisit if it becomes a problem.
|
||||||
|
|
||||||
|
- **Q: Tab-strip visual style** — match `ClideTabBar` exactly, or
|
||||||
|
introduce a denser variant for inside-pane use? Recommendation:
|
||||||
|
reuse `ClideTabBar` initially; spin off a `ClideTabBar.dense`
|
||||||
|
variant only if visual hierarchy issues emerge.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"name": "ClideCollapserCard",
|
||||||
|
"shapes": {
|
||||||
|
"title": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 40, "top": 28,
|
||||||
|
"text": "ClideCollapserCard — one collapser primitive for all group / tool cards",
|
||||||
|
"fontColor": "#E2E8F5", "fontSize": 18
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 40, "top": 56,
|
||||||
|
"text": "Each card type grabs the same widget (no cross-type collapsing) · color property drives border + label/text · fixed-width counter slot · status icon hard against the card edge",
|
||||||
|
"fontColor": "#6A7280", "fontSize": 12
|
||||||
|
},
|
||||||
|
|
||||||
|
"lbl-collapsed": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 40, "top": 100,
|
||||||
|
"text": "COLLAPSED (ticker row = toggle, focusable)",
|
||||||
|
"fontColor": "#6A7280", "fontSize": 11
|
||||||
|
},
|
||||||
|
|
||||||
|
"a1": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 124, "width": 660, "height": 46,
|
||||||
|
"fillColor": "#21262F", "strokeColor": "#393E48", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"a1-chev": { "type": "Text", "parent": "a1", "left": 52, "top": 138, "text": "›", "fontColor": "#6A7280", "fontSize": 16 },
|
||||||
|
"a1-label": { "type": "Text", "parent": "a1", "left": 74, "top": 140, "text": "Activity", "fontColor": "#6A7280", "fontSize": 12 },
|
||||||
|
"a1-summary": { "type": "Text", "parent": "a1", "left": 150, "top": 140, "text": "Read conversation_view.dart", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a1-count": { "type": "Text", "parent": "a1", "left": 560, "top": 140, "text": "3 steps", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a1-status": { "type": "Text", "parent": "a1", "left": 676, "top": 139, "text": "✓", "fontColor": "#00AB9A", "fontSize": 14 },
|
||||||
|
|
||||||
|
"a2": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 178, "width": 660, "height": 46,
|
||||||
|
"fillColor": "#21262F", "strokeColor": "#00AB9A", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"a2-chev": { "type": "Text", "parent": "a2", "left": 52, "top": 192, "text": "›", "fontColor": "#00AB9A", "fontSize": 16 },
|
||||||
|
"a2-label": { "type": "Text", "parent": "a2", "left": 74, "top": 194, "text": "Edits", "fontColor": "#00AB9A", "fontSize": 12 },
|
||||||
|
"a2-summary": { "type": "Text", "parent": "a2", "left": 150, "top": 194, "text": "clide_markdown.dart", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a2-count": { "type": "Text", "parent": "a2", "left": 560, "top": 194, "text": "7 edits", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a2-status": { "type": "Text", "parent": "a2", "left": 674, "top": 193, "text": "◐", "fontColor": "#00A3D2", "fontSize": 14 },
|
||||||
|
|
||||||
|
"a3": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 232, "width": 660, "height": 46,
|
||||||
|
"fillColor": "#21262F", "strokeColor": "#F06C6F", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"a3-chev": { "type": "Text", "parent": "a3", "left": 52, "top": 246, "text": "›", "fontColor": "#F06C6F", "fontSize": 16 },
|
||||||
|
"a3-label": { "type": "Text", "parent": "a3", "left": 74, "top": 248, "text": "Bash", "fontColor": "#F06C6F", "fontSize": 12 },
|
||||||
|
"a3-summary": { "type": "Text", "parent": "a3", "left": 150, "top": 248, "text": "npm test", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a3-count": { "type": "Text", "parent": "a3", "left": 560, "top": 248, "text": "1 step", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"a3-status": { "type": "Text", "parent": "a3", "left": 676, "top": 247, "text": "✕", "fontColor": "#F06C6F", "fontSize": 14 },
|
||||||
|
|
||||||
|
"counter-note": { "type": "Text", "left": 470, "top": 96, "text": "counters share a fixed-width right-aligned slot ▾ so the status icon never shifts", "fontColor": "#D08447", "fontSize": 10 },
|
||||||
|
|
||||||
|
"edge-note": { "type": "Text", "left": 470, "top": 286, "text": "status icon hard against the card edge ▲ (was inboard, left of the counter)", "fontColor": "#D08447", "fontSize": 10 },
|
||||||
|
"left-note": { "type": "Text", "left": 40, "top": 286, "text": "chevron hard against the left edge — the toggle (keyboard / AT focusable)", "fontColor": "#D08447", "fontSize": 10 },
|
||||||
|
|
||||||
|
"lbl-expanded": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 40, "top": 332,
|
||||||
|
"text": "EXPANDED (same header; framed body wraps the nested cards — which are the SAME primitive)",
|
||||||
|
"fontColor": "#6A7280", "fontSize": 11
|
||||||
|
},
|
||||||
|
|
||||||
|
"b": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 356, "width": 660, "height": 196,
|
||||||
|
"fillColor": "#21262F", "strokeColor": "#00AB9A", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"b-header": {
|
||||||
|
"type": "Rectangle", "parent": "b",
|
||||||
|
"left": 40, "top": 356, "width": 660, "height": 40,
|
||||||
|
"fillColor": "#292E38", "strokeColor": "#393E48", "corners": [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
"b-chev": { "type": "Text", "parent": "b-header", "left": 52, "top": 368, "text": "⌄", "fontColor": "#00AB9A", "fontSize": 16 },
|
||||||
|
"b-label": { "type": "Text", "parent": "b-header", "left": 74, "top": 369, "text": "Edits", "fontColor": "#00AB9A", "fontSize": 12 },
|
||||||
|
"b-count": { "type": "Text", "parent": "b-header", "left": 560, "top": 369, "text": "7 edits", "fontColor": "#6A7280", "fontSize": 11 },
|
||||||
|
"b-status": { "type": "Text", "parent": "b-header", "left": 676, "top": 368, "text": "✓", "fontColor": "#00AB9A", "fontSize": 14 },
|
||||||
|
|
||||||
|
"sub1": {
|
||||||
|
"type": "Rectangle", "parent": "b",
|
||||||
|
"left": 56, "top": 408, "width": 628, "height": 40,
|
||||||
|
"fillColor": "#1A1E24", "strokeColor": "#393E48", "corners": [3, 3, 3, 3]
|
||||||
|
},
|
||||||
|
"sub1-chev": { "type": "Text", "parent": "sub1", "left": 66, "top": 420, "text": "›", "fontColor": "#6A7280", "fontSize": 14 },
|
||||||
|
"sub1-label": { "type": "Text", "parent": "sub1", "left": 86, "top": 421, "text": "Edit", "fontColor": "#FA5F8B", "fontSize": 11 },
|
||||||
|
"sub1-sum": { "type": "Text", "parent": "sub1", "left": 150, "top": 421, "text": "clide_markdown.dart · _urlLinkSpan", "fontColor": "#6A7280", "fontSize": 10 },
|
||||||
|
"sub1-status": { "type": "Text", "parent": "sub1", "left": 660, "top": 420, "text": "✓", "fontColor": "#00AB9A", "fontSize": 13 },
|
||||||
|
|
||||||
|
"sub2": {
|
||||||
|
"type": "Rectangle", "parent": "b",
|
||||||
|
"left": 56, "top": 454, "width": 628, "height": 40,
|
||||||
|
"fillColor": "#1A1E24", "strokeColor": "#393E48", "corners": [3, 3, 3, 3]
|
||||||
|
},
|
||||||
|
"sub2-chev": { "type": "Text", "parent": "sub2", "left": 66, "top": 466, "text": "›", "fontColor": "#6A7280", "fontSize": 14 },
|
||||||
|
"sub2-label": { "type": "Text", "parent": "sub2", "left": 86, "top": 467, "text": "Edit", "fontColor": "#FA5F8B", "fontSize": 11 },
|
||||||
|
"sub2-sum": { "type": "Text", "parent": "sub2", "left": 150, "top": 467, "text": "clide_markdown.dart · _isHttpUrl", "fontColor": "#6A7280", "fontSize": 10 },
|
||||||
|
"sub2-status": { "type": "Text", "parent": "sub2", "left": 660, "top": 466, "text": "✓", "fontColor": "#00AB9A", "fontSize": 13 },
|
||||||
|
|
||||||
|
"sub3": {
|
||||||
|
"type": "Rectangle", "parent": "b",
|
||||||
|
"left": 56, "top": 500, "width": 628, "height": 40,
|
||||||
|
"fillColor": "#1A1E24", "strokeColor": "#393E48", "corners": [3, 3, 3, 3]
|
||||||
|
},
|
||||||
|
"sub3-chev": { "type": "Text", "parent": "sub3", "left": 66, "top": 512, "text": "›", "fontColor": "#6A7280", "fontSize": 14 },
|
||||||
|
"sub3-label": { "type": "Text", "parent": "sub3", "left": 86, "top": 513, "text": "Edit", "fontColor": "#FA5F8B", "fontSize": 11 },
|
||||||
|
"sub3-sum": { "type": "Text", "parent": "sub3", "left": 150, "top": 513, "text": "clide_markdown.dart · build()", "fontColor": "#6A7280", "fontSize": 10 },
|
||||||
|
"sub3-status": { "type": "Text", "parent": "sub3", "left": 660, "top": 512, "text": "◐", "fontColor": "#00A3D2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"legend": {
|
||||||
|
"type": "Text",
|
||||||
|
"left": 40, "top": 572,
|
||||||
|
"text": "Status glyph (right edge): ◐ running (logo-mark spinner) · ✓ success · ✕ error — set per-instance by a single `color` (border + label/text) + a `ClideRunStatus`.",
|
||||||
|
"fontColor": "#6A7280", "fontSize": 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — AskUserQuestion (single)",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 40, "width": 840, "height": 330,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 40, "width": 840, "height": 4,
|
||||||
|
"fillColor": "#5aa0e0", "strokeColor": "#5aa0e0", "corners": [8, 8, 0, 0]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 60,
|
||||||
|
"text": "question", "fontColor": "#5aa0e0", "fontSize": 13
|
||||||
|
},
|
||||||
|
"qtext": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 86,
|
||||||
|
"text": "Which fruit is your favorite?", "fontColor": "#c8d0e0", "fontSize": 17
|
||||||
|
},
|
||||||
|
"opt-banana": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 126, "width": 150, "height": 34,
|
||||||
|
"fillColor": "#27406a", "strokeColor": "#5aa0e0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-banana-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 78, "top": 134,
|
||||||
|
"text": "① Banana", "fontColor": "#dbe6f5", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-apple": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 224, "top": 126, "width": 130, "height": 34,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-apple-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 238, "top": 134,
|
||||||
|
"text": "② Apple", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-mango": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 364, "top": 126, "width": 130, "height": 34,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-mango-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 378, "top": 134,
|
||||||
|
"text": "③ Mango", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-other": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 504, "top": 126, "width": 130, "height": 34,
|
||||||
|
"fillColor": "#1f242c", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-other-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 518, "top": 134,
|
||||||
|
"text": "Other…", "fontColor": "#9aa4b2", "fontSize": 14
|
||||||
|
},
|
||||||
|
"note-hint": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 176,
|
||||||
|
"text": "✎ add a note to your choice (optional)", "fontColor": "#7a8290", "fontSize": 12
|
||||||
|
},
|
||||||
|
"note-field": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 198, "width": 752, "height": 32,
|
||||||
|
"fillColor": "#14181d", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"note-field-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 78, "top": 206,
|
||||||
|
"text": "optional note…", "fontColor": "#5b6470", "fontSize": 13
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"type": "Line", "parent": "panel", "left": 64, "top": 256, "width": 752, "height": 1,
|
||||||
|
"strokeColor": "#2a3038"
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 276, "width": 120, "height": 34,
|
||||||
|
"fillColor": "#3b5bdb", "strokeColor": "#3b5bdb", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"submit-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 96, "top": 284,
|
||||||
|
"text": "Submit", "fontColor": "#ffffff", "fontSize": 14
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 700, "top": 284,
|
||||||
|
"text": "› chat instead", "fontColor": "#7a8290", "fontSize": 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — AskUserQuestion (stepper)",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 40, "top": 40, "width": 840, "height": 330,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"type": "Rectangle", "left": 40, "top": 40, "width": 840, "height": 4,
|
||||||
|
"fillColor": "#5aa0e0", "strokeColor": "#5aa0e0", "corners": [8, 8, 0, 0]
|
||||||
|
},
|
||||||
|
"nav-left": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 62, "text": "‹", "fontColor": "#7a8290", "fontSize": 16
|
||||||
|
},
|
||||||
|
"chip-pet-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 88, "top": 64, "text": "1 · Pet ✓", "fontColor": "#5cb87a", "fontSize": 13
|
||||||
|
},
|
||||||
|
"chip-eaten": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 188, "top": 58, "width": 120, "height": 26,
|
||||||
|
"fillColor": "#27406a", "strokeColor": "#5aa0e0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"chip-eaten-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 200, "top": 64, "text": "2 · Eaten", "fontColor": "#dbe6f5", "fontSize": 13
|
||||||
|
},
|
||||||
|
"chip-review-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 326, "top": 64, "text": "Review", "fontColor": "#7a8290", "fontSize": 13
|
||||||
|
},
|
||||||
|
"nav-right": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 404, "top": 62, "text": "›", "fontColor": "#7a8290", "fontSize": 16
|
||||||
|
},
|
||||||
|
"step-count": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 770, "top": 64, "text": "2 of 2", "fontColor": "#5b6470", "fontSize": 12
|
||||||
|
},
|
||||||
|
"divider-top": {
|
||||||
|
"type": "Line", "parent": "panel", "left": 64, "top": 94, "width": 752, "height": 1, "strokeColor": "#2a3038"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 106, "text": "EATEN", "fontColor": "#7a8290", "fontSize": 12
|
||||||
|
},
|
||||||
|
"qtext": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 126, "text": "How do you most often eat your fruit?", "fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"opt-fresh": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 162, "width": 160, "height": 34,
|
||||||
|
"fillColor": "#27406a", "strokeColor": "#5aa0e0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-fresh-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 78, "top": 170, "text": "① Fresh / whole", "fontColor": "#dbe6f5", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-smoothie": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 234, "top": 162, "width": 140, "height": 34,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-smoothie-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 248, "top": 170, "text": "② Smoothie", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-salad": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 384, "top": 162, "width": 120, "height": 34,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-salad-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 398, "top": 170, "text": "③ Salad", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-other": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 514, "top": 162, "width": 120, "height": 34,
|
||||||
|
"fillColor": "#1f242c", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-other-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 528, "top": 170, "text": "Other…", "fontColor": "#9aa4b2", "fontSize": 14
|
||||||
|
},
|
||||||
|
"note-field": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 212, "width": 752, "height": 30,
|
||||||
|
"fillColor": "#14181d", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"note-field-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 78, "top": 219, "text": "✎ add a note (optional)", "fontColor": "#5b6470", "fontSize": 12
|
||||||
|
},
|
||||||
|
"divider-bot": {
|
||||||
|
"type": "Line", "parent": "panel", "left": 64, "top": 262, "width": 752, "height": 1, "strokeColor": "#2a3038"
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 278, "width": 86, "height": 34,
|
||||||
|
"fillColor": "#1f242c", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"back-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 84, "top": 286, "text": "‹ Back", "fontColor": "#9aa4b2", "fontSize": 14
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 160, "top": 278, "width": 110, "height": 34,
|
||||||
|
"fillColor": "#3b5bdb", "strokeColor": "#3b5bdb", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"next-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 188, "top": 286, "text": "Next ›", "fontColor": "#ffffff", "fontSize": 14
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 700, "top": 286, "text": "› chat instead", "fontColor": "#7a8290", "fontSize": 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — AskUserQuestion (review)",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle", "left": 40, "top": 40, "width": 840, "height": 270,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"type": "Rectangle", "left": 40, "top": 40, "width": 840, "height": 4,
|
||||||
|
"fillColor": "#5cb87a", "strokeColor": "#5cb87a", "corners": [8, 8, 0, 0]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 60, "text": "review", "fontColor": "#5cb87a", "fontSize": 13
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 84, "text": "Review your answers", "fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"row1-q": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 124, "text": "Pet", "fontColor": "#7a8290", "fontSize": 13
|
||||||
|
},
|
||||||
|
"row1-a": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 180, "top": 124, "text": "→ Banana — only the big ripe ones", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"row2-q": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 152, "text": "Eaten", "fontColor": "#7a8290", "fontSize": 13
|
||||||
|
},
|
||||||
|
"row2-a": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 180, "top": 152, "text": "→ Fresh / whole", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"type": "Line", "parent": "panel", "left": 64, "top": 196, "width": 752, "height": 1, "strokeColor": "#2a3038"
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 214, "width": 86, "height": 34,
|
||||||
|
"fillColor": "#1f242c", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"back-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 84, "top": 222, "text": "‹ Back", "fontColor": "#9aa4b2", "fontSize": 14
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 160, "top": 214, "width": 160, "height": 34,
|
||||||
|
"fillColor": "#2f9e44", "strokeColor": "#2f9e44", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"submit-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 188, "top": 222, "text": "Submit answers", "fontColor": "#ffffff", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — permission prompt",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle", "left": 40, "top": 40, "width": 840, "height": 300,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"type": "Rectangle", "left": 40, "top": 40, "width": 840, "height": 4,
|
||||||
|
"fillColor": "#d9a441", "strokeColor": "#d9a441", "corners": [8, 8, 0, 0]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 60, "text": "permission · Write", "fontColor": "#d9a441", "fontSize": 14
|
||||||
|
},
|
||||||
|
"desc": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 86, "text": "/tmp/banana.txt", "fontColor": "#9aa4b2", "fontSize": 13
|
||||||
|
},
|
||||||
|
"opt-allow": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 118, "width": 120, "height": 34,
|
||||||
|
"fillColor": "#2f9e44", "strokeColor": "#2f9e44", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-allow-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 86, "top": 126, "text": "① Allow", "fontColor": "#ffffff", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-always": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 194, "top": 118, "width": 230, "height": 34,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-always-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 208, "top": 126, "text": "② Allow & don't ask again", "fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"opt-deny": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 434, "top": 118, "width": 110, "height": 34,
|
||||||
|
"fillColor": "#3a2630", "strokeColor": "#c0506a", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"opt-deny-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 456, "top": 126, "text": "③ Deny", "fontColor": "#e58ba0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"deny-hint": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 64, "top": 174, "text": "tell Claude what to do differently (optional)", "fontColor": "#7a8290", "fontSize": 12
|
||||||
|
},
|
||||||
|
"deny-field": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 196, "width": 752, "height": 32,
|
||||||
|
"fillColor": "#14181d", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"deny-field-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 78, "top": 204, "text": "tell Claude what to do differently…", "fontColor": "#5b6470", "fontSize": 13
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"type": "Line", "parent": "panel", "left": 64, "top": 252, "width": 752, "height": 1, "strokeColor": "#2a3038"
|
||||||
|
},
|
||||||
|
"send-deny": {
|
||||||
|
"type": "Rectangle", "parent": "panel", "left": 64, "top": 266, "width": 130, "height": 34,
|
||||||
|
"fillColor": "#c0392b", "strokeColor": "#c0392b", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"send-deny-t": {
|
||||||
|
"type": "Text", "parent": "panel", "left": 86, "top": 274, "text": "Deny + note", "fontColor": "#ffffff", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — team cockpit sidebar",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {"type": "Rectangle", "left": 40, "top": 24, "width": 340, "height": 740, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]},
|
||||||
|
|
||||||
|
"h-team": {"type": "Text", "parent": "panel", "left": 58, "top": 40, "text": "TEAM", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
|
||||||
|
"d-main": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 70, "width": 9, "height": 9, "fillColor": "#d97757", "strokeColor": "#d97757", "corners": [2, 2, 2, 2]},
|
||||||
|
"n-main": {"type": "Text", "parent": "panel", "left": 74, "top": 66, "text": "main · lead", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
"s-main": {"type": "Text", "parent": "panel", "left": 170, "top": 67, "text": "synthesizing", "fontColor": "#7a8290", "fontSize": 11},
|
||||||
|
"p-main": {"type": "Rectangle", "parent": "panel", "left": 336, "top": 64, "width": 22, "height": 18, "fillColor": "#1f2a44", "strokeColor": "#5aa0e0", "corners": [3, 3, 3, 3]},
|
||||||
|
"pt-main": {"type": "Text", "parent": "panel", "left": 343, "top": 65, "text": "P", "fontColor": "#5aa0e0", "fontSize": 12},
|
||||||
|
|
||||||
|
"d-tyre": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 104, "width": 9, "height": 9, "fillColor": "#b08cff", "strokeColor": "#b08cff", "corners": [2, 2, 2, 2]},
|
||||||
|
"n-tyre": {"type": "Text", "parent": "panel", "left": 74, "top": 100, "text": "tyre", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
"s-tyre": {"type": "Text", "parent": "panel", "left": 170, "top": 101, "text": "thinking · 38%", "fontColor": "#7a8290", "fontSize": 11},
|
||||||
|
"p-tyre": {"type": "Rectangle", "parent": "panel", "left": 336, "top": 98, "width": 22, "height": 18, "fillColor": "#1d2e22", "strokeColor": "#5cb87a", "corners": [3, 3, 3, 3]},
|
||||||
|
"pt-tyre": {"type": "Text", "parent": "panel", "left": 343, "top": 99, "text": "A", "fontColor": "#5cb87a", "fontSize": 12},
|
||||||
|
|
||||||
|
"d-burn": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 138, "width": 9, "height": 9, "fillColor": "#e0a04a", "strokeColor": "#e0a04a", "corners": [2, 2, 2, 2]},
|
||||||
|
"n-burn": {"type": "Text", "parent": "panel", "left": 74, "top": 134, "text": "burnelli", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
"s-burn": {"type": "Text", "parent": "panel", "left": 170, "top": 135, "text": "writing · 26%", "fontColor": "#7a8290", "fontSize": 11},
|
||||||
|
"p-burn": {"type": "Rectangle", "parent": "panel", "left": 336, "top": 132, "width": 22, "height": 18, "fillColor": "#1d2e22", "strokeColor": "#5cb87a", "corners": [3, 3, 3, 3]},
|
||||||
|
"pt-burn": {"type": "Text", "parent": "panel", "left": 343, "top": 133, "text": "A", "fontColor": "#5cb87a", "fontSize": 12},
|
||||||
|
|
||||||
|
"d-gest": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 172, "width": 9, "height": 9, "fillColor": "#e36fb0", "strokeColor": "#e36fb0", "corners": [2, 2, 2, 2]},
|
||||||
|
"n-gest": {"type": "Text", "parent": "panel", "left": 74, "top": 168, "text": "gestalt", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
"s-gest": {"type": "Text", "parent": "panel", "left": 170, "top": 169, "text": "reading · 26%", "fontColor": "#7a8290", "fontSize": 11},
|
||||||
|
"p-gest": {"type": "Rectangle", "parent": "panel", "left": 336, "top": 166, "width": 22, "height": 18, "fillColor": "#22262e", "strokeColor": "#7a8290", "corners": [3, 3, 3, 3]},
|
||||||
|
"pt-gest": {"type": "Text", "parent": "panel", "left": 343, "top": 167, "text": "D", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
|
||||||
|
"d-nig": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 206, "width": 9, "height": 9, "fillColor": "#3fb9b0", "strokeColor": "#3fb9b0", "corners": [2, 2, 2, 2]},
|
||||||
|
"n-nig": {"type": "Text", "parent": "panel", "left": 74, "top": 202, "text": "nigel", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
"s-nig": {"type": "Text", "parent": "panel", "left": 170, "top": 203, "text": "thinking · 32%", "fontColor": "#7a8290", "fontSize": 11},
|
||||||
|
"p-nig": {"type": "Rectangle", "parent": "panel", "left": 336, "top": 200, "width": 22, "height": 18, "fillColor": "#1d2e22", "strokeColor": "#5cb87a", "corners": [3, 3, 3, 3]},
|
||||||
|
"pt-nig": {"type": "Text", "parent": "panel", "left": 343, "top": 201, "text": "A", "fontColor": "#5cb87a", "fontSize": 12},
|
||||||
|
|
||||||
|
"perm-note": {"type": "Text", "parent": "panel", "left": 58, "top": 232, "text": "click the mode badge to cycle (D→A→P) · shift-click for bypass", "fontColor": "#5b6470", "fontSize": 10},
|
||||||
|
|
||||||
|
"div1": {"type": "Line", "parent": "panel", "left": 58, "top": 256, "width": 304, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
|
||||||
|
"h-tasks": {"type": "Text", "parent": "panel", "left": 58, "top": 268, "text": "TASKS", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
"t1": {"type": "Text", "parent": "panel", "left": 58, "top": 294, "text": "◐ tile-type taxonomy", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"t1a": {"type": "Text", "parent": "panel", "left": 330, "top": 294, "text": "tyre", "fontColor": "#b08cff", "fontSize": 11},
|
||||||
|
"t2": {"type": "Text", "parent": "panel", "left": 58, "top": 318, "text": "○ reverse requirements", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"t2a": {"type": "Text", "parent": "panel", "left": 322, "top": 318, "text": "gestalt", "fontColor": "#e36fb0", "fontSize": 11},
|
||||||
|
"t3": {"type": "Text", "parent": "panel", "left": 58, "top": 342, "text": "✓ water-model brief", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
|
||||||
|
"div2": {"type": "Line", "parent": "panel", "left": 58, "top": 372, "width": 304, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
|
||||||
|
"h-msg": {"type": "Text", "parent": "panel", "left": 58, "top": 384, "text": "MESSAGES", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
"popout": {"type": "Text", "parent": "panel", "left": 300, "top": 384, "text": "⤢ pop out", "fontColor": "#5aa0e0", "fontSize": 11},
|
||||||
|
"m1n": {"type": "Text", "parent": "panel", "left": 58, "top": 410, "text": "tyre → lead", "fontColor": "#b08cff", "fontSize": 11},
|
||||||
|
"m1t": {"type": "Text", "parent": "panel", "left": 58, "top": 426, "text": "opening position: tiles are a discrete…", "fontColor": "#9aa4b2", "fontSize": 11},
|
||||||
|
"m2n": {"type": "Text", "parent": "panel", "left": 58, "top": 452, "text": "lead → you", "fontColor": "#d97757", "fontSize": 11},
|
||||||
|
"m2t": {"type": "Text", "parent": "panel", "left": 58, "top": 468, "text": "relaying gestalt's flat-taxonomy take…", "fontColor": "#9aa4b2", "fontSize": 11},
|
||||||
|
"m3n": {"type": "Text", "parent": "panel", "left": 58, "top": 494, "text": "you → team", "fontColor": "#5aa0e0", "fontSize": 11},
|
||||||
|
"m3t": {"type": "Text", "parent": "panel", "left": 58, "top": 510, "text": "focus on tile types only", "fontColor": "#9aa4b2", "fontSize": 11},
|
||||||
|
|
||||||
|
"postbox": {"type": "Rectangle", "parent": "panel", "left": 58, "top": 700, "width": 304, "height": 36, "fillColor": "#14181d", "strokeColor": "#333340", "corners": [4, 4, 4, 4]},
|
||||||
|
"post-t": {"type": "Text", "parent": "panel", "left": 70, "top": 710, "text": "@team message…", "fontColor": "#5b6470", "fontSize": 12}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — team chat pane",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {"type": "Rectangle", "left": 40, "top": 30, "width": 860, "height": 540, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]},
|
||||||
|
"accent": {"type": "Rectangle", "left": 40, "top": 30, "width": 860, "height": 4, "fillColor": "#5aa0e0", "strokeColor": "#5aa0e0", "corners": [8, 8, 0, 0]},
|
||||||
|
"title": {"type": "Text", "parent": "panel", "left": 60, "top": 48, "text": "team chat", "fontColor": "#5aa0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"c1": {"type": "Text", "parent": "panel", "left": 60, "top": 80, "text": "tyre → lead", "fontColor": "#b08cff", "fontSize": 12},
|
||||||
|
"m1": {"type": "Text", "parent": "panel", "left": 60, "top": 98, "text": "Opening position: a tile is a discrete surface kind on a body; propose 7 base kinds + 2 modifiers.", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"c2": {"type": "Text", "parent": "panel", "left": 60, "top": 134, "text": "gestalt → lead", "fontColor": "#e36fb0", "fontSize": 12},
|
||||||
|
"m2": {"type": "Text", "parent": "panel", "left": 60, "top": 152, "text": "I'd keep it flat — a composable set bloats to hundreds of near-duplicates. One rationale per kind.", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"c3": {"type": "Text", "parent": "panel", "left": 60, "top": 188, "text": "you → team", "fontColor": "#5aa0e0", "fontSize": 12},
|
||||||
|
"m3": {"type": "Text", "parent": "panel", "left": 60, "top": 206, "text": "Let's focus on tile types only this round — hold the broader derivation for later.", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"c4": {"type": "Text", "parent": "panel", "left": 60, "top": 242, "text": "lead → you", "fontColor": "#d97757", "fontSize": 12},
|
||||||
|
"m4": {"type": "Text", "parent": "panel", "left": 60, "top": 260, "text": "On it — refocusing the room to a tile-type taxonomy; I'll relay each opening position as it lands.", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"c5": {"type": "Text", "parent": "panel", "left": 60, "top": 296, "text": "nigel → you", "fontColor": "#3fb9b0", "fontSize": 12},
|
||||||
|
"m5": {"type": "Text", "parent": "panel", "left": 60, "top": 314, "text": "@you do types combine/compose for variety, or is a flat list enough? It changes my taxonomy.", "fontColor": "#c8d0e0", "fontSize": 13},
|
||||||
|
|
||||||
|
"divider": {"type": "Line", "parent": "panel", "left": 60, "top": 470, "width": 820, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
|
||||||
|
"to": {"type": "Rectangle", "parent": "panel", "left": 60, "top": 488, "width": 110, "height": 34, "fillColor": "#1f2a3a", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4]},
|
||||||
|
"to-t": {"type": "Text", "parent": "panel", "left": 74, "top": 496, "text": "@nigel ▾", "fontColor": "#9aa4b2", "fontSize": 13},
|
||||||
|
"input": {"type": "Rectangle", "parent": "panel", "left": 178, "top": 488, "width": 470, "height": 34, "fillColor": "#14181d", "strokeColor": "#333340", "corners": [4, 4, 4, 4]},
|
||||||
|
"input-t": {"type": "Text", "parent": "panel", "left": 192, "top": 496, "text": "they compose — pick from 7 base + 2 modifiers", "fontColor": "#5b6470", "fontSize": 13},
|
||||||
|
|
||||||
|
"intr-box": {"type": "Rectangle", "parent": "panel", "left": 660, "top": 494, "width": 16, "height": 16, "fillColor": "#14181d", "strokeColor": "#7a8290", "corners": [3, 3, 3, 3]},
|
||||||
|
"intr-t": {"type": "Text", "parent": "panel", "left": 682, "top": 496, "text": "interrupt", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
|
||||||
|
"send": {"type": "Rectangle", "parent": "panel", "left": 770, "top": 488, "width": 90, "height": 34, "fillColor": "#3b5bdb", "strokeColor": "#3b5bdb", "corners": [4, 4, 4, 4]},
|
||||||
|
"send-t": {"type": "Text", "parent": "panel", "left": 794, "top": 496, "text": "Send", "fontColor": "#ffffff", "fontSize": 14}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "Clide — sidebar Activity tab",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {"type": "Rectangle", "left": 40, "top": 24, "width": 340, "height": 740, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]},
|
||||||
|
|
||||||
|
"tab-a": {"type": "Text", "parent": "panel", "left": 58, "top": 40, "text": "Activity", "fontColor": "#5aa0e0", "fontSize": 13},
|
||||||
|
"tab-a-u": {"type": "Rectangle", "parent": "panel", "left": 56, "top": 60, "width": 60, "height": 2, "fillColor": "#5aa0e0", "strokeColor": "#5aa0e0", "corners": [0, 0, 0, 0]},
|
||||||
|
"tab-t": {"type": "Text", "parent": "panel", "left": 140, "top": 40, "text": "Team", "fontColor": "#7a8290", "fontSize": 13},
|
||||||
|
"tab-c": {"type": "Text", "parent": "panel", "left": 210, "top": 40, "text": "Config", "fontColor": "#7a8290", "fontSize": 13},
|
||||||
|
"strip": {"type": "Line", "parent": "panel", "left": 40, "top": 62, "width": 340, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
|
||||||
|
"h-today": {"type": "Text", "parent": "panel", "left": 58, "top": 78, "text": "TODAY", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
"k1": {"type": "Text", "parent": "panel", "left": 58, "top": 100, "text": "messages", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v1": {"type": "Text", "parent": "panel", "left": 200, "top": 100, "text": "142", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"k2": {"type": "Text", "parent": "panel", "left": 58, "top": 120, "text": "sessions", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v2": {"type": "Text", "parent": "panel", "left": 200, "top": 120, "text": "12", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"k3": {"type": "Text", "parent": "panel", "left": 58, "top": 140, "text": "tool calls", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v3": {"type": "Text", "parent": "panel", "left": 200, "top": 140, "text": "318", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
|
||||||
|
"div1": {"type": "Line", "parent": "panel", "left": 58, "top": 168, "width": 304, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
"h-life": {"type": "Text", "parent": "panel", "left": 58, "top": 182, "text": "LIFETIME", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
"k4": {"type": "Text", "parent": "panel", "left": 58, "top": 204, "text": "messages", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v4": {"type": "Text", "parent": "panel", "left": 200, "top": 204, "text": "4,120", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"k5": {"type": "Text", "parent": "panel", "left": 58, "top": 224, "text": "sessions", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v5": {"type": "Text", "parent": "panel", "left": 200, "top": 224, "text": "286", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
|
||||||
|
"div2": {"type": "Line", "parent": "panel", "left": 58, "top": 252, "width": 304, "height": 1, "strokeColor": "#2a3038"},
|
||||||
|
"h-run": {"type": "Text", "parent": "panel", "left": 58, "top": 266, "text": "RUNTIME · primary", "fontColor": "#7a8290", "fontSize": 12},
|
||||||
|
"k6": {"type": "Text", "parent": "panel", "left": 58, "top": 288, "text": "model", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v6": {"type": "Text", "parent": "panel", "left": 200, "top": 288, "text": "claude-opus-4-7 [1M]", "fontColor": "#d97757", "fontSize": 12},
|
||||||
|
"k7": {"type": "Text", "parent": "panel", "left": 58, "top": 308, "text": "context", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v7": {"type": "Text", "parent": "panel", "left": 200, "top": 308, "text": "54%", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"k8": {"type": "Text", "parent": "panel", "left": 58, "top": 328, "text": "mode", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v8": {"type": "Text", "parent": "panel", "left": 200, "top": 328, "text": "accept-edits", "fontColor": "#c8d0e0", "fontSize": 12},
|
||||||
|
"k9": {"type": "Text", "parent": "panel", "left": 58, "top": 348, "text": "skills", "fontColor": "#9aa4b2", "fontSize": 12},
|
||||||
|
"v9": {"type": "Text", "parent": "panel", "left": 200, "top": 348, "text": "14", "fontColor": "#c8d0e0", "fontSize": 12}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 31 KiB |