Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ tsconfig.tsbuildinfo
.opencode/dag-init.json
.opencode/workflow-drafts/
.opencode/workflow-reports/
# >>> specgit: local delivery assets (managed by specgit init) >>>
/.specgit.yaml
/spec_git/
# <<< specgit: local delivery assets (managed by specgit init) <<<
26 changes: 26 additions & 0 deletions .opencode/command/specgit-doctor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
description: Diagnose the SpecGit environment probes and drive the exit-3 repair loop
---

<!-- specgit-managed-entry-point -->

# /specgit-doctor

Thin trigger for the exit-3 diagnostic loop. The canonical behavior lives in
the AGENTS.md SpecGit block; this command only launches it.

## Steps

1. Run from the repo root:

```bash
specgit doctor --json
```

2. Read `probes[]`: every failing probe carries a `code` (git, repo,
origin, gh/glab presence and auth, policy).
3. Fix exactly what the failing probe names, then re-run
`specgit doctor --json` until exit 0.
4. Return to the verdict: `specgit finish --json`. Exit 3 is environment,
never delivery — do not edit the record or the policy to work around it.
5. `--json` is the only parse surface.
2 changes: 2 additions & 0 deletions .opencode/command/specgit-finish.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
description: Run the SpecGit evidence verdict and drive the fix loop to exit 0
---

<!-- specgit-managed-entry-point -->

# /specgit-finish

Thin trigger for the acceptance verdict. The canonical behavior lives in the
Expand Down
9 changes: 8 additions & 1 deletion .opencode/command/specgit-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
description: Start a SpecGit delivery from a title or existing issue number
---

<!-- specgit-managed-entry-point -->

# /specgit-issue

Thin trigger for the delivery bootstrap. The canonical behavior lives in the
Expand All @@ -17,6 +19,11 @@ AGENTS.md SpecGit block; this command only launches it.
specgit issue "$ARGUMENTS" --json
```

3. On success report the brief: issue URL(s), PR URL (draft), branch name.
3. On success report the brief: issue URL(s), PR URL (draft), branch name —
then fill each issue body it created (Why / Scope / Approach /
Acceptance) from the discussion with `gh issue edit <n>`, then
implement. Fill in the draft PR's scaffold (Why / What changed /
Evidence) as you deliver; its placeholders are advisory, never gates,
and the closing references stay intact.
4. Switch to the delivery branch and begin the TDD loop.
5. On error, read `errors[].fix` and follow it — never bypass the record.
27 changes: 27 additions & 0 deletions .opencode/command/specgit-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
description: Repair the SpecGit PR binding — auto-discover by head branch or bind explicitly
---

<!-- specgit-managed-entry-point -->

# /specgit-pr

Thin trigger for PR-binding repair. The canonical behavior lives in the
AGENTS.md SpecGit block; this command only launches it.

## Steps

1. Run from the delivery branch:

```bash
specgit pr --json
```

2. Branch on the result:
- `exit 0` → the record's PR binding is repaired; resume the delivery.
- `pr_not_found` → push the branch (re-running `specgit issue`
resumes the bootstrap), then rerun this command.
- `pr_ambiguous` → several open PRs share the head branch; bind one
explicitly: `specgit pr <number>`.
3. `specgit pr` owns the PR binding; never hand-edit `.specgit.yaml`.
`--json` is the only parse surface.
28 changes: 28 additions & 0 deletions .opencode/command/specgit-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
description: Show local SpecGit evidence — record, delivery state, drift, origin
---

<!-- specgit-managed-entry-point -->

# /specgit-status

Thin trigger for local evidence. The canonical behavior lives in the
AGENTS.md SpecGit block; this command only launches it.

## Steps

1. Run from the repo root:

```bash
specgit status --json
```

2. Read `state` and `record` from the envelope: local evidence only —
record, drift, origin. Platform evidence (issues, PR, checks) belongs
to `specgit finish`.
3. No record is not an error: `state: "unbound"` with exit `0` is the
normal pre-binding state — bootstrap with `specgit issue` (the
`record_missing` warning carries the next step in `warnings[].fix`).
Exit `3` is different: `state: "unknown"`, a genuine evidence
failure — read `errors[].fix`.
4. Never hand-edit `.specgit.yaml`.
17 changes: 16 additions & 1 deletion .opencode/hooks/specgit-merge-guard.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/sh
# SpecGit merge guard (managed by specgit init). Exit 2 = block with reason.
# SpecGit guard (managed by specgit init): start gate + merge guard. Exit 2 = block with reason.
GUARD_DIR=$(cd "$(dirname "$0")" && pwd)
export GUARD_DIR
# Hook payloads arrive as the first argument or on stdin; accept both.
Expand All @@ -8,6 +8,21 @@ if [ -n "$1" ]; then
else
payload=$(cat)
fi
tool=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_name)||'')}catch{process.stdout.write('')}})")
case "$tool" in
edit|write|Edit|Write)
# Start gate (#335): mutating files requires the delivery binding on
# THIS branch. The record's context.branch is written by specgit and
# matched as a fixed WHOLE line — no YAML parsing, no prefix collision
# (branch "feat/1-a" must never satisfy a record for "feat/1-a2").
branch=$(git branch --show-current 2>/dev/null)
if [ -z "$branch" ] || [ ! -f .specgit.yaml ] || ! grep -qFx " branch: $branch" .specgit.yaml; then
echo "specgit: start gate - this branch has no delivery binding. Start the delivery first: specgit issue \"<type>: <title>\", then fill each issue body from the discussion, then edit files." >&2
exit 2
fi
exit 0
;;
esac
command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})")

case "$command" in
Expand Down
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
version: 1
delivery: ad-hoc-sign
delivery: giant-summary-guard
context:
kind: branch
branch: fix/454-ad-hoc-sign
branch: feat/458-giant-summary-guard
issues:
- 454
pr: 455
- 458
pr: 459
50 changes: 48 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ This repository uses a multi-context domain-document layout rooted at `CONTEXT-M
Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; re-apply each deviation after every re-init:

- `specgit-accept.yml` drops the template's `workflow_dispatch` trigger. Dispatch is the privileged context that fires CodeQL's cache-poisoning taint rule on the `head_ref` checkout (false positive: no cache use, read-only token, `persist-credentials: false`), and on dispatch events `head_ref` is empty so the verdict would evaluate the default branch — the wrong tree. Delivery here always goes through a PR. The head-ref checkout itself must NOT be replaced with a SHA: `specgit finish` requires HEAD on the delivery branch (detached_head otherwise).
- `specgit-accept.yml` installs the CLI globally (`npm install -g specgit@^0.5.0`), NOT the template's `npm install --no-save specgit@X`: a workspace-local install reads this bun workspace's package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, #434, again on #459). Re-apply after every `specgit init --force`.
- `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names.

<!-- specgit:block:start -->
Expand All @@ -283,13 +284,30 @@ already exists); keep manual guidance outside them.
deterministic scaffold (the `Closes #n` line for every bound issue,
then Why / What changed / Evidence / Checklist sections), and writes
`.specgit.yaml`. Re-running resumes; it is idempotent.
- Fill in the scaffold sections as you deliver. Its placeholders are
advisory — the closing references are the only body gate. The PR body
- Issue bodies are filled at bootstrap, from the conversation: right after
`specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`)
with the discussed Why / Scope / Approach / Acceptance, then implement.
The PR scaffold's placeholders are advisory — fill those sections in as
you deliver; the closing references are the only body gate. The PR body
is written once at creation; no SpecGit command edits an existing PR
body, and the repository's own pull-request template is never read.
- A draft pull request always fails the verdict (`pr_draft`): before
`specgit finish`, mark it ready for review — `gh pr ready <number>`
on GitHub, `glab mr update <number> --ready` on GitLab.
- Finish with `specgit finish`: the verdict, derived from real git, PR,
and CI evidence. Exit code 0 is the only "done".

### Issue tags

- Every bootstrap applies the title's `kind::<type>` member
automatically; pass `--tags <a,b>` to choose the full set explicitly.
- Selection is pool-first: existing on-spec labels win verbatim; anything
missing is seeded from the built-in `kind::` catalog or the policy's
`tags:` declarations. Unknown vocabulary exits 2 naming the universe.
- Choose with restraint: at most one label per axis, none when unsure —
off-spec pool labels are reported (`tag_pool_dirty` warnings are for
humans) and never renamed by SpecGit.

### Repair and diagnostics

- `specgit pr` repairs the pull-request binding: with no arguments it
Expand Down Expand Up @@ -333,6 +351,34 @@ verified on its own evidence, split it before binding.
- Never weaken `spec_git/policy.yaml` to make a verdict pass.
- `--json` is the only parse surface: stdout is exactly one JSON
document; never scrape human-readable output.

### Agent contract essentials

- **SpecGit is the default way of working here.** Any non-trivial
task — a feature, a fix, a refactor, a docs change — is a delivery:
work items live in this tracker as issues, never in private task
lists or conversational checklists. The trigger is the decision to
start: the moment the conversation settles and you begin turning
the plan into changes, the FIRST action is
`specgit issue <type>: <title>...` — before any file edit.
Working without a binding is a contract violation, not a style
choice. Immediately after bootstrap, fill each issue body
(Why / Scope / Approach / Acceptance) from the discussion with
`gh issue edit`, then implement. Mid-conversation inventories
("let me list everything to do") become issues, not chat
artifacts. Trivial replies and read-only questions need none of
this.
- The one rule: a delivery is done if and only if `specgit finish`
exits `0`. Never declare completion from task lists, file states, or
test runs you performed yourself.
- Branch on exit codes, not phrasing: `1` = evidence complete, fix what
the gates named; `3` = evidence missing, fix the environment first
(`specgit doctor`). Never present exit `3` as success.
- Keep the `Closes #n` references in the PR body intact; after changing
the PR body, head branch, or CI, re-run `specgit finish`. Never
bypass or reconfig a required check to make acceptance pass.
- Forge evidence flows through the user's authenticated CLI session only
(`gh` / `glab`): never read, log, or pass around tokens.
<!-- specgit:block:end -->

## Tool-call discipline (hard rules)
Expand Down
49 changes: 47 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,30 @@ already exists); keep manual guidance outside them.
deterministic scaffold (the `Closes #n` line for every bound issue,
then Why / What changed / Evidence / Checklist sections), and writes
`.specgit.yaml`. Re-running resumes; it is idempotent.
- Fill in the scaffold sections as you deliver. Its placeholders are
advisory — the closing references are the only body gate. The PR body
- Issue bodies are filled at bootstrap, from the conversation: right after
`specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`)
with the discussed Why / Scope / Approach / Acceptance, then implement.
The PR scaffold's placeholders are advisory — fill those sections in as
you deliver; the closing references are the only body gate. The PR body
is written once at creation; no SpecGit command edits an existing PR
body, and the repository's own pull-request template is never read.
- A draft pull request always fails the verdict (`pr_draft`): before
`specgit finish`, mark it ready for review — `gh pr ready <number>`
on GitHub, `glab mr update <number> --ready` on GitLab.
- Finish with `specgit finish`: the verdict, derived from real git, PR,
and CI evidence. Exit code 0 is the only "done".

### Issue tags

- Every bootstrap applies the title's `kind::<type>` member
automatically; pass `--tags <a,b>` to choose the full set explicitly.
- Selection is pool-first: existing on-spec labels win verbatim; anything
missing is seeded from the built-in `kind::` catalog or the policy's
`tags:` declarations. Unknown vocabulary exits 2 naming the universe.
- Choose with restraint: at most one label per axis, none when unsure —
off-spec pool labels are reported (`tag_pool_dirty` warnings are for
humans) and never renamed by SpecGit.

### Repair and diagnostics

- `specgit pr` repairs the pull-request binding: with no arguments it
Expand Down Expand Up @@ -240,4 +257,32 @@ verified on its own evidence, split it before binding.
- Never weaken `spec_git/policy.yaml` to make a verdict pass.
- `--json` is the only parse surface: stdout is exactly one JSON
document; never scrape human-readable output.

### Agent contract essentials

- **SpecGit is the default way of working here.** Any non-trivial
task — a feature, a fix, a refactor, a docs change — is a delivery:
work items live in this tracker as issues, never in private task
lists or conversational checklists. The trigger is the decision to
start: the moment the conversation settles and you begin turning
the plan into changes, the FIRST action is
`specgit issue <type>: <title>...` — before any file edit.
Working without a binding is a contract violation, not a style
choice. Immediately after bootstrap, fill each issue body
(Why / Scope / Approach / Acceptance) from the discussion with
`gh issue edit`, then implement. Mid-conversation inventories
("let me list everything to do") become issues, not chat
artifacts. Trivial replies and read-only questions need none of
this.
- The one rule: a delivery is done if and only if `specgit finish`
exits `0`. Never declare completion from task lists, file states, or
test runs you performed yourself.
- Branch on exit codes, not phrasing: `1` = evidence complete, fix what
the gates named; `3` = evidence missing, fix the environment first
(`specgit doctor`). Never present exit `3` as success.
- Keep the `Closes #n` references in the PR body intact; after changing
the PR body, head branch, or CI, re-run `specgit finish`. Never
bypass or reconfig a required check to make acceptance pass.
- Forge evidence flows through the user's authenticated CLI session only
(`gh` / `glab`): never read, log, or pass around tokens.
<!-- specgit:block:end -->
27 changes: 25 additions & 2 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,37 @@ export function isDefaultTitle(title: string) {

type SessionRow = typeof SessionTable.$inferSelect

export const MAX_SUMMARY_DIFF_BYTES = 256 * 1024

// Byte accounting mirrors the JSON serialization: 2 bytes for the "[]" wrapper,
// +1 per comma separator, so kept output never exceeds MAX_SUMMARY_DIFF_BYTES.
export function truncateSummaryDiffs(diffs: Snapshot.FileDiff[] | undefined) {
if (!diffs) return undefined
let total = 2
const kept: Snapshot.FileDiff[] = []
for (const item of diffs) {
const size = Buffer.byteLength(JSON.stringify(item)) + (kept.length > 0 ? 1 : 0)
if (total + size > MAX_SUMMARY_DIFF_BYTES) break
total += size
kept.push(item)
}
return kept
}

function stripOversizedDiffs<T>(diffs: T[] | null | undefined) {
if (!diffs) return undefined
if (Buffer.byteLength(JSON.stringify(diffs)) > MAX_SUMMARY_DIFF_BYTES) return undefined
return diffs
}

export function fromRow(row: SessionRow): Info {
const summary =
row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
? {
additions: row.summary_additions ?? 0,
deletions: row.summary_deletions ?? 0,
files: row.summary_files ?? 0,
diffs: row.summary_diffs ?? undefined,
diffs: stripOversizedDiffs(row.summary_diffs),
}
: undefined
const share = row.share_url ? { url: row.share_url } : undefined
Expand Down Expand Up @@ -142,7 +165,7 @@ export function toRow(info: Info) {
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs,
summary_diffs: truncateSummaryDiffs(info.summary?.diffs),
metadata: info.metadata,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? EmptyTokens).input,
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { Session, truncateSummaryDiffs } from "./session"
import { SessionID, MessageID } from "./schema"
import { Config } from "@/config/config"

Expand Down Expand Up @@ -122,7 +122,7 @@ export const layer = Layer.effect(
const target = messages.find((m) => m.info.id === input.messageID)
if (!target || target.info.role !== "user") return
const msgDiffs = yield* computeDiff({ messages })
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
target.info.summary = { ...target.info.summary, diffs: truncateSummaryDiffs(msgDiffs) ?? [] }
yield* sessions.updateMessage(target.info)
})

Expand Down
Loading
Loading