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
116 changes: 116 additions & 0 deletions .agents/skills/design-contracts/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
name: design-contracts
description: Enforces smriti's three design contracts (observability, dry-run, versioning) when writing or modifying CLI command handlers or JSON output.
---

# smriti Design Contract Guardrails

This skill activates whenever you are **adding or modifying a CLI command**,
**changing JSON output**, **touching telemetry/logging code**, or **altering
config defaults** in the smriti project.

---

## Contract 1 — Dry Run

### Mutating commands MUST support `--dry-run`

The following commands write to disk, the database, or the network. Every one of
them **must** honour `--dry-run`:

| Command | Expected guard pattern |
| ------------ | ----------------------------------------------------------------------------- |
| `ingest` | `const dryRun = hasFlag(args, "--dry-run");` then no DB/file writes when true |
| `embed` | same |
| `categorize` | same |
| `tag` | same |
| `share` | same |
| `sync` | same |
| `context` | already implemented — keep it |

When `--dry-run` is active:

- `stdout` must describe **what would happen** (e.g. `Would ingest N sessions`).
- `stderr` must note what was skipped (`No changes were made (--dry-run)`).
- Exit code follows normal success/error rules — dry-run is NOT an error.
- If `--json` is also set, the output envelope must include
`"meta": { "dry_run": true }`.

### Read-only commands MUST reject `--dry-run`

These commands never mutate state. If they receive `--dry-run`, they must print
a usage error and `process.exit(1)`:

`search`, `recall`, `list`, `status`, `show`, `compare`, `projects`, `team`,
`categories`

---

## Contract 2 — Observability / Telemetry

### Never log user content

The following are **forbidden** in any `console.log`, `console.error`, or
log/audit output:

- Message content (`.content`, `.text`, `.body`)
- Query strings passed by the user
- Memory text or embedding data
- File paths provided by the user (as opposed to system-derived paths)

✅ OK to log: command name, exit code, duration, session IDs, counts, smriti
version.

### Telemetry default must be OFF

- `SMRITI_TELEMETRY` must default to `0`/`false`/`"off"` — never `1`.
- Telemetry calls must be guarded: `if (telemetryEnabled) { ... }`.
- Any new telemetry signal must be added to `smriti telemetry sample` output.

---

## Contract 3 — JSON & CLI Versioning

### JSON output is a hard contract

The standard output envelope is:

```json
{ "ok": true, "data": { ... }, "meta": { ... } }
```

Rules:

- **Never remove a field** from `data` or `meta` — add `@deprecated` in a
comment instead.
- **Never rename a field**.
- **Never change a field's type** (e.g. string → number).
- New fields in `data` or `meta` must be **optional**.
- If you must replace a field: add the new one AND keep the old one with a
`_deprecated: true` sibling or comment.

### CLI interface stability

Once a command or flag has shipped:

- **Command names**: frozen.
- **Flag names**: frozen. You may add aliases (e.g. `--dry-run` → `-n`) but not
rename.
- **Positional argument order**: frozen.
- **Deprecated flags**: must keep working, must emit a `stderr` warning.

---

## Pre-Submission Checklist

Before finishing any edit that touches `src/index.ts` or a command handler:

- [ ] If command is mutating → `--dry-run` is supported and guarded
- [ ] If command is read-only → `--dry-run` is rejected with a usage error
- [ ] No user-supplied content appears in `console.log`/`console.error`
- [ ] If JSON output changed → only fields were **added**, not
removed/renamed/retyped
- [ ] If a new flag was added → it does not conflict with any existing flag name
- [ ] Telemetry default remains off in `config.ts`

If any item fails, fix it before proceeding.
19 changes: 19 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/dev-to-main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Release Summary
- Version: `v<package.json.version>`
- Source: `dev`
- Target: `main`
- Scope: promote validated changes from `dev` to `main`

## Changes Included
<!-- AUTO-GENERATED:COMMITS -->
- _Auto-filled by workflow from PR commits._
<!-- /AUTO-GENERATED:COMMITS -->

## Validation
- [ ] CI passed on `dev`
- [ ] Perf bench reviewed (if relevant)
- [ ] Breaking changes documented
- [ ] Release notes verified

## Notes
- Replace or extend this section with any release-specific context.
102 changes: 98 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,31 @@ on:
branches: [main, dev]

jobs:
test:
test-pr:
if: github.event_name == 'pull_request'
name: Test (ubuntu-latest)
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Run tests
# Fast PR validation on Linux only.
run: bun test test/

test-merge:
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
Expand All @@ -30,7 +54,77 @@ jobs:
run: bun install

- name: Run tests
# We only run tests in the smriti/test directory.
# qmd/ tests are skipped here as they are part of the backbone submodule
# and may have heavy dependencies (like local LLMs) that the runner lacks.
# Full cross-platform test matrix for merge branches.
run: bun test test/

dev-draft-release:
name: Dev Draft Release
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
needs: test-merge
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive

- name: Compute dev tag
id: tag
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
DEV_TAG="v${BASE_VERSION}-dev.${{ github.run_number }}"
echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"

- name: Remove previous dev draft releases and tags
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;

const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});

for (const release of releases) {
const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
if (isDevTag && release.draft) {
await github.rest.repos.deleteRelease({
owner,
repo,
release_id: release.id,
});
}
}

const refs = await github.paginate(github.rest.git.listMatchingRefs, {
owner,
repo,
ref: "tags/v",
per_page: 100,
});
for (const ref of refs) {
const tagName = ref.ref.replace("refs/tags/", "");
if (/-dev\.\d+$/.test(tagName)) {
await github.rest.git.deleteRef({
owner,
repo,
ref: `tags/${tagName}`,
}).catch(() => {});
}
}

- name: Create draft prerelease
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.dev_tag }}
target_commitish: ${{ github.sha }}
name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
generate_release_notes: true
draft: true
prerelease: true
57 changes: 57 additions & 0 deletions .github/workflows/dev-main-pr-template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Dev->Main PR Autofill

on:
pull_request:
types: [opened, reopened, synchronize]
branches: [main]

jobs:
autofill:
if: github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Auto-set title and body
uses: actions/github-script@v7
with:
script: |
const fs = require("fs");
const path = ".github/PULL_REQUEST_TEMPLATE/dev-to-main.md";
const template = fs.readFileSync(path, "utf8");

const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;

const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
const version = pkg.version || "0.0.0";
const title = `release: v${version} (dev -> main)`;

const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number,
per_page: 100,
});
const commitLines = commits.map((c) => `- ${c.commit.message.split("\n")[0]} (${c.sha.slice(0, 7)})`);
const commitsText = commitLines.length ? commitLines.join("\n") : "- No commits found.";

const body = template
.replace("`v<package.json.version>`", `v${version}`)
.replace(
/<!-- AUTO-GENERATED:COMMITS -->[\s\S]*?<!-- \/AUTO-GENERATED:COMMITS -->/m,
`<!-- AUTO-GENERATED:COMMITS -->\n${commitsText}\n<!-- /AUTO-GENERATED:COMMITS -->`
);

await github.rest.pulls.update({
owner,
repo,
pull_number,
title,
body,
});
Loading