@c0x12c/spartan-ai-toolkit 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +0 -20
- package/VERSION +1 -1
- package/commands/spartan/build.md +88 -90
- package/package.json +1 -1
- package/rules/core/TIMEZONE.md +176 -121
- package/rules/database/SCHEMA.md +5 -5
- package/templates/build-config.yaml +2 -21
package/README.md
CHANGED
|
@@ -359,24 +359,6 @@ For other tools, copy the rule files from `toolkit/rules/` into your tool's conf
|
|
|
359
359
|
|
|
360
360
|
---
|
|
361
361
|
|
|
362
|
-
## Parallel Builds
|
|
363
|
-
|
|
364
|
-
By default, `/spartan:build` creates a **git worktree** per feature — a separate directory with its own branch. This means you can build 2+ features in parallel from different terminals:
|
|
365
|
-
|
|
366
|
-
```bash
|
|
367
|
-
# Terminal 1 # Terminal 2
|
|
368
|
-
claude claude
|
|
369
|
-
> /spartan:build auth > /spartan:build payments
|
|
370
|
-
# → .claude/worktrees/feature-auth/ # → .claude/worktrees/feature-payments/
|
|
371
|
-
# → PR #1 # → PR #2
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
No conflicts. Each session gets its own worktree, branch, and PR.
|
|
375
|
-
|
|
376
|
-
To disable worktrees (single-terminal mode), set `worktree: false` in `.spartan/build.yaml`.
|
|
377
|
-
|
|
378
|
-
---
|
|
379
|
-
|
|
380
362
|
## Project Config
|
|
381
363
|
|
|
382
364
|
Customize any Spartan command per project. Two config files in `.spartan/`:
|
|
@@ -386,11 +368,9 @@ Customize any Spartan command per project. Two config files in `.spartan/`:
|
|
|
386
368
|
Controls `/spartan:build` behavior:
|
|
387
369
|
|
|
388
370
|
```yaml
|
|
389
|
-
worktree: true # git worktree per feature (default: true)
|
|
390
371
|
branch-prefix: "feature" # branch name: [prefix]/[slug]
|
|
391
372
|
max-review-rounds: 3 # review-fix cycles before asking user
|
|
392
373
|
skip-stages: [] # skip: spec, design, plan, ship (never review)
|
|
393
|
-
worktree-symlinks: [] # extra dirs to share across worktrees
|
|
394
374
|
|
|
395
375
|
prompts:
|
|
396
376
|
spec: |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.9.
|
|
1
|
+
1.9.2
|
|
@@ -14,22 +14,23 @@ You decide which steps to run, which skills to call, and when to move forward. T
|
|
|
14
14
|
```
|
|
15
15
|
SINGLE FEATURE:
|
|
16
16
|
|
|
17
|
-
Context → Spec → Design? → Plan → Implement → Review Agent → Fix → Ship
|
|
18
|
-
│ │ │
|
|
19
|
-
.memory/ Gate 1 Design
|
|
20
|
-
Gate
|
|
17
|
+
Context → Spec → Design? → Plan+Worktree → Implement → Review Agent → Fix → Ship
|
|
18
|
+
│ │ │ │ │ │ │ │
|
|
19
|
+
.memory/ Gate 1 Design git worktree Gate 3 Spawn agent Loop Gate 4
|
|
20
|
+
Gate .worktrees/slug fix until OK
|
|
21
21
|
|
|
22
22
|
EPIC (multi-feature — auto-detected):
|
|
23
23
|
|
|
24
|
-
Context → Epic
|
|
25
|
-
│ │
|
|
26
|
-
.planning/
|
|
27
|
-
epics/
|
|
24
|
+
Context → Epic → Per feature: Spec/Design/Plan → Worktree → Implement → Review → Ship
|
|
25
|
+
│ │ │ │ │ │ │
|
|
26
|
+
.planning/ read epic fill gaps git worktree parallel Loop one PR
|
|
27
|
+
epics/ .worktrees/ by dep
|
|
28
28
|
|
|
29
|
-
PARALLEL
|
|
29
|
+
PARALLEL (multiple terminals — automatic):
|
|
30
30
|
|
|
31
|
-
/spartan:build auth →
|
|
32
|
-
/spartan:build payments →
|
|
31
|
+
Terminal 1: /spartan:build auth → .worktrees/auth/ (feature/auth) → PR #1
|
|
32
|
+
Terminal 2: /spartan:build payments → .worktrees/payments/ (feature/payments) → PR #2
|
|
33
|
+
(each gets its own worktree, branch, and PR — no conflicts, no manual setup)
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
**Fast path:** For small work (< 1 day, ≤ 4 tasks), you do spec + plan inline. No separate commands needed.
|
|
@@ -51,9 +52,7 @@ PARALLEL BUILDS (automatic — each build in its own worktree):
|
|
|
51
52
|
|
|
52
53
|
---
|
|
53
54
|
|
|
54
|
-
## FIRST: Load Build Config
|
|
55
|
-
|
|
56
|
-
### Load project build config
|
|
55
|
+
## FIRST: Load Build Config (silent — no questions)
|
|
57
56
|
|
|
58
57
|
Check for a project-level build config that overrides default behavior:
|
|
59
58
|
|
|
@@ -65,11 +64,9 @@ If `.spartan/build.yaml` exists, read it and apply overrides. All fields are opt
|
|
|
65
64
|
|
|
66
65
|
| Field | Default | What it does |
|
|
67
66
|
|-------|---------|-------------|
|
|
68
|
-
| `worktree` | `true` | Create a git worktree per build. Set `false` for single-terminal workflow. |
|
|
69
67
|
| `branch-prefix` | `"feature"` | Branch name: `[prefix]/[slug]` (e.g., `feature/user-auth`) |
|
|
70
68
|
| `max-review-rounds` | `3` | Max review-fix cycles before asking the user |
|
|
71
|
-
| `skip-stages` | `[]` | Stages to skip. Valid: `spec`, `design`, `plan`, `
|
|
72
|
-
| `worktree-symlinks` | `[]` | Extra gitignored dirs to symlink into worktrees |
|
|
69
|
+
| `skip-stages` | `[]` | Stages to skip. Valid: `spec`, `design`, `plan`, `ship`. Never `review`. |
|
|
73
70
|
| `prompts.spec` | — | Custom instructions injected after spec questions |
|
|
74
71
|
| `prompts.plan` | — | Custom instructions injected into the plan stage |
|
|
75
72
|
| `prompts.implement` | — | Custom instructions injected during implementation |
|
|
@@ -80,55 +77,6 @@ If `.spartan/build.yaml` exists, read it and apply overrides. All fields are opt
|
|
|
80
77
|
|
|
81
78
|
**If config has `skip-stages`**, skip those stages. But NEVER skip `review` even if listed — review is always mandatory.
|
|
82
79
|
|
|
83
|
-
### Enter Worktree
|
|
84
|
-
|
|
85
|
-
**Default: every build runs in a git worktree.** A worktree is a separate directory with its own branch — lets you run `/spartan:build` in multiple terminals without conflicts.
|
|
86
|
-
|
|
87
|
-
**Check if already in a worktree:**
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
git rev-parse --show-toplevel 2>/dev/null
|
|
91
|
-
git worktree list 2>/dev/null | head -3
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
If already in a worktree (not the main repo), skip — you're already isolated.
|
|
95
|
-
|
|
96
|
-
**If `worktree: false` in config**, skip worktree creation. Use `git checkout -b` later in Stage 3 instead.
|
|
97
|
-
|
|
98
|
-
**Otherwise, create a worktree now:**
|
|
99
|
-
|
|
100
|
-
Generate a slug from the feature description (e.g., "user auth" → `user-auth`). Use `branch-prefix` from config (default: `feature`).
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
EnterWorktree(name: "[prefix]-[slug]")
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
This creates a worktree at `.claude/worktrees/[prefix]-[slug]/` with a new branch and switches your working directory there.
|
|
107
|
-
|
|
108
|
-
**Symlink shared directories** — gitignored dirs don't appear in worktrees. Symlink them from the main repo:
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
MAIN_REPO="$(git worktree list | head -1 | awk '{print $1}')"
|
|
112
|
-
SYMLINKS=".planning .memory .handoff .spartan"
|
|
113
|
-
|
|
114
|
-
# Add extra symlinks from config (worktree-symlinks field)
|
|
115
|
-
for dir in $SYMLINKS; do
|
|
116
|
-
[ -d "$MAIN_REPO/$dir" ] && [ ! -e "$dir" ] && ln -s "$MAIN_REPO/$dir" "$dir"
|
|
117
|
-
done
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
> "Working in worktree: `[path]` on branch `[prefix]-[slug]`"
|
|
121
|
-
|
|
122
|
-
**If `EnterWorktree` fails** (e.g., name conflict), fall back to manual worktree:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
SLUG="[slug]"
|
|
126
|
-
MAIN_REPO="$(pwd)"
|
|
127
|
-
git worktree add "$MAIN_REPO/.worktrees/$SLUG" -b "feature/$SLUG" 2>/dev/null
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Then tell the user: "Created worktree at `.worktrees/[slug]/`. Open a new terminal there and run `claude` + `/spartan:build`."
|
|
131
|
-
|
|
132
80
|
---
|
|
133
81
|
|
|
134
82
|
## Step 0: Detect Mode & Stack (silent — no questions)
|
|
@@ -359,23 +307,47 @@ Uses skills: `ui-ux-pro-max`, frontend rules
|
|
|
359
307
|
|
|
360
308
|
**CRITICAL: Full-stack means BOTH layers must complete.** Don't move to Gate 3 after finishing backend only. The plan must include frontend tasks and ALL tasks must be done before review. If the spec mentions UI changes, API responses shown to users, or any user-facing behavior — frontend tasks are mandatory.
|
|
361
309
|
|
|
362
|
-
###
|
|
310
|
+
### Create feature workspace
|
|
311
|
+
|
|
312
|
+
Each build runs in its own **git worktree** — a separate directory with its own branch. This happens automatically. The user doesn't set anything up. Multiple terminals running `/spartan:build` get separate worktrees, branches, and PRs.
|
|
363
313
|
|
|
364
|
-
|
|
314
|
+
Use `branch-prefix` from config (default: `feature`). Generate a slug from the feature name.
|
|
365
315
|
|
|
366
316
|
```bash
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
317
|
+
SLUG="[slug]"
|
|
318
|
+
BRANCH="feature/$SLUG"
|
|
319
|
+
MAIN_REPO="$(git rev-parse --show-toplevel)"
|
|
320
|
+
WORKSPACE="$MAIN_REPO/.worktrees/$SLUG"
|
|
321
|
+
|
|
322
|
+
# Create worktree (or reuse if resuming)
|
|
323
|
+
if [ -d "$WORKSPACE" ]; then
|
|
324
|
+
echo "RESUMING: Worktree exists at $WORKSPACE"
|
|
325
|
+
else
|
|
326
|
+
git worktree add "$WORKSPACE" -b "$BRANCH" 2>/dev/null || git worktree add "$WORKSPACE" "$BRANCH"
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
# Symlink shared dirs (gitignored, won't appear in worktree)
|
|
330
|
+
for dir in .planning .memory .handoff .spartan; do
|
|
331
|
+
[ -d "$MAIN_REPO/$dir" ] && [ ! -e "$WORKSPACE/$dir" ] && ln -s "$MAIN_REPO/$dir" "$WORKSPACE/$dir"
|
|
332
|
+
done
|
|
370
333
|
|
|
371
|
-
|
|
334
|
+
# Copy .env if exists (API keys, secrets)
|
|
335
|
+
[ -f "$MAIN_REPO/.env" ] && [ ! -f "$WORKSPACE/.env" ] && cp "$MAIN_REPO/.env" "$WORKSPACE/.env"
|
|
372
336
|
|
|
373
|
-
|
|
337
|
+
# Gitignore worktrees dir
|
|
338
|
+
grep -qxF '.worktrees/' "$MAIN_REPO/.gitignore" 2>/dev/null || echo '.worktrees/' >> "$MAIN_REPO/.gitignore"
|
|
374
339
|
|
|
375
|
-
|
|
376
|
-
|
|
340
|
+
echo "WORKSPACE=$WORKSPACE"
|
|
341
|
+
echo "BRANCH=$BRANCH"
|
|
377
342
|
```
|
|
378
343
|
|
|
344
|
+
**From this point, ALL work happens in `$WORKSPACE`:**
|
|
345
|
+
- Bash commands: `cd $WORKSPACE && ./gradlew test`
|
|
346
|
+
- File reads/writes: use `$WORKSPACE/src/...` absolute paths
|
|
347
|
+
- Git operations: `git -C $WORKSPACE ...`
|
|
348
|
+
|
|
349
|
+
> "Working in: `$WORKSPACE` on branch `$BRANCH`"
|
|
350
|
+
|
|
379
351
|
**Custom plan prompts:** If `.spartan/build.yaml` has `prompts.plan`, apply those instructions now.
|
|
380
352
|
|
|
381
353
|
Write the first failing test for Task 1. Show it fails.
|
|
@@ -772,6 +744,20 @@ mkdir -p .memory/decisions .memory/patterns .memory/knowledge
|
|
|
772
744
|
|
|
773
745
|
Update `.memory/index.md` if you saved anything.
|
|
774
746
|
|
|
747
|
+
### Clean up worktree
|
|
748
|
+
|
|
749
|
+
After PR is created, the worktree stays in case the user needs to push review fixes. Tell the user:
|
|
750
|
+
|
|
751
|
+
> "PR created. Worktree at `.worktrees/[slug]` is still active for review fixes."
|
|
752
|
+
|
|
753
|
+
When the user says the PR is merged:
|
|
754
|
+
|
|
755
|
+
```bash
|
|
756
|
+
MAIN_REPO="$(git worktree list | head -1 | awk '{print $1}')"
|
|
757
|
+
git -C "$MAIN_REPO" worktree remove ".worktrees/[slug]" --force 2>/dev/null
|
|
758
|
+
git -C "$MAIN_REPO" worktree prune 2>/dev/null
|
|
759
|
+
```
|
|
760
|
+
|
|
775
761
|
**GATE 4 — Done.**
|
|
776
762
|
> "PR created: [link]. Here's what's in it: [summary]."
|
|
777
763
|
|
|
@@ -828,20 +814,30 @@ Agent(
|
|
|
828
814
|
```
|
|
829
815
|
Collect results after all finish, then `TeamDelete()`.
|
|
830
816
|
|
|
831
|
-
### Step 2: Sort by dependency
|
|
817
|
+
### Step 2: Sort by dependency and create workspace
|
|
832
818
|
|
|
833
819
|
Read the epic's Features table. Sort features by dependency order:
|
|
834
820
|
- Features with no dependencies → can build first
|
|
835
821
|
- Features that depend on others → build after their dependencies are done
|
|
836
822
|
|
|
837
|
-
|
|
838
|
-
**If `worktree: false`:** Create a branch: `git checkout -b feature/[epic-slug]`
|
|
823
|
+
Create a worktree for the epic (same pattern as single-feature builds):
|
|
839
824
|
|
|
840
825
|
```bash
|
|
841
|
-
|
|
842
|
-
|
|
826
|
+
SLUG="[epic-slug]"
|
|
827
|
+
BRANCH="feature/$SLUG"
|
|
828
|
+
MAIN_REPO="$(git rev-parse --show-toplevel)"
|
|
829
|
+
WORKSPACE="$MAIN_REPO/.worktrees/$SLUG"
|
|
830
|
+
|
|
831
|
+
git worktree add "$WORKSPACE" -b "$BRANCH" 2>/dev/null || git worktree add "$WORKSPACE" "$BRANCH"
|
|
832
|
+
|
|
833
|
+
for dir in .planning .memory .handoff .spartan; do
|
|
834
|
+
[ -d "$MAIN_REPO/$dir" ] && [ ! -e "$WORKSPACE/$dir" ] && ln -s "$MAIN_REPO/$dir" "$WORKSPACE/$dir"
|
|
835
|
+
done
|
|
836
|
+
[ -f "$MAIN_REPO/.env" ] && [ ! -f "$WORKSPACE/.env" ] && cp "$MAIN_REPO/.env" "$WORKSPACE/.env"
|
|
843
837
|
```
|
|
844
838
|
|
|
839
|
+
All epic work happens in `$WORKSPACE`.
|
|
840
|
+
|
|
845
841
|
### Step 3: Implement in dependency order
|
|
846
842
|
|
|
847
843
|
Go through features in dependency order. **When 2+ features have no dependency between them, build them at the same time using Agent Teams** (if enabled). Otherwise build one by one.
|
|
@@ -909,13 +905,19 @@ Run the full test suite. Then continue to **Stage 5: Review** — the review age
|
|
|
909
905
|
If a previous session was interrupted (context overflow, user stopped, etc.), this workflow can resume.
|
|
910
906
|
|
|
911
907
|
**How resume works:**
|
|
912
|
-
1.
|
|
913
|
-
|
|
908
|
+
1. Check for existing worktrees from a previous build:
|
|
909
|
+
```bash
|
|
910
|
+
ls .worktrees/ 2>/dev/null
|
|
911
|
+
git worktree list 2>/dev/null
|
|
912
|
+
```
|
|
913
|
+
If a worktree exists for this feature, set `WORKSPACE` to it and work there. Don't create a new one.
|
|
914
|
+
2. Step 0.5 checks for `.handoff/` files and existing `.planning/` artifacts
|
|
915
|
+
3. Determine which stage was completed last:
|
|
914
916
|
- Has spec but no plan → resume at Stage 3 (Plan)
|
|
915
917
|
- Has plan but no commits on feature branch → resume at Stage 4 (Implement)
|
|
916
918
|
- Has commits but no PR → resume at Stage 5 (Review) or Stage 6 (Ship)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
+
4. Show the user: "Resuming from [stage] in `$WORKSPACE`. Here's what was done: [summary]."
|
|
920
|
+
5. Continue from that point.
|
|
919
921
|
|
|
920
922
|
**Don't re-do completed stages.** Read the saved artifacts and move forward.
|
|
921
923
|
|
|
@@ -938,8 +940,8 @@ If a previous session was interrupted (context overflow, user stopped, etc.), th
|
|
|
938
940
|
- **Full-stack = both layers done.** If the feature touches both backend and frontend, you MUST implement both before creating the PR. Backend-only completion is NOT "done" for a full-stack feature.
|
|
939
941
|
- **Epic = one branch, one PR.** When building from an epic, all features go on one branch and ship as one PR. Don't create separate PRs per feature. Parallelize independent features with Agent Teams when available.
|
|
940
942
|
- **Epic auto-detection.** If the user's feature name matches an epic in `.planning/epics/`, switch to epic mode automatically. Don't ask.
|
|
941
|
-
- **
|
|
942
|
-
- **
|
|
943
|
+
- **Build config is `.spartan/build.yaml`.** Controls branch prefix, max review rounds, skip stages, and custom prompts per stage. All fields optional.
|
|
944
|
+
- **Every build creates a worktree automatically.** `git worktree add .worktrees/[slug]` runs at Stage 3. All work happens in the worktree using `$WORKSPACE` paths. Multiple terminals get separate worktrees, branches, and PRs. No conflicts. Cleanup happens after PR merge.
|
|
943
945
|
|
|
944
946
|
---
|
|
945
947
|
|
|
@@ -974,7 +976,3 @@ A feature is NOT done until every applicable item is checked:
|
|
|
974
976
|
- [ ] Review agent passed (all HIGH/MEDIUM issues fixed)
|
|
975
977
|
- [ ] PR created with summary and test plan
|
|
976
978
|
|
|
977
|
-
### Worktree
|
|
978
|
-
- [ ] Worktree created and working directory switched (automatic unless `worktree: false`)
|
|
979
|
-
- [ ] All commits pushed to feature branch
|
|
980
|
-
- [ ] Worktree cleaned up after PR merged (`git worktree remove .claude/worktrees/[slug]`)
|
package/package.json
CHANGED
package/rules/core/TIMEZONE.md
CHANGED
|
@@ -4,20 +4,80 @@
|
|
|
4
4
|
|
|
5
5
|
**Server stores UTC. API sends UTC. API receives UTC. No exceptions.**
|
|
6
6
|
|
|
7
|
-
The frontend is the only place that converts to/from local time —
|
|
7
|
+
The frontend is the only place that converts to/from local time — for display only.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
Database (UTC) → Backend (UTC) → API JSON (
|
|
11
|
-
|
|
10
|
+
Database (TIMESTAMPTZ/UTC) → Backend (Instant/UTC) → API JSON (ISO 8601 Z) → Frontend (UTC) → Display (local)
|
|
11
|
+
← Send (local → UTC) ← Input
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Database
|
|
17
|
+
|
|
18
|
+
### Use `TIMESTAMPTZ` — Not `TIMESTAMP`
|
|
19
|
+
|
|
20
|
+
**Always use `TIMESTAMPTZ` (with timezone).** PostgreSQL docs and wiki both say this.
|
|
21
|
+
|
|
22
|
+
Why: `TIMESTAMPTZ` converts to UTC on insert and converts back on read. If a connection has a non-UTC session timezone (DBA tools, connection pool quirks, migration scripts), `TIMESTAMPTZ` still stores the correct UTC value. `TIMESTAMP` without timezone silently stores whatever you give it — if the session isn't UTC, you get wrong data and can't tell.
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
-- CORRECT — TIMESTAMPTZ is the safe default
|
|
26
|
+
CREATE TABLE events (
|
|
27
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
28
|
+
starts_at TIMESTAMPTZ NOT NULL,
|
|
29
|
+
ends_at TIMESTAMPTZ NOT NULL,
|
|
30
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
31
|
+
updated_at TIMESTAMPTZ,
|
|
32
|
+
deleted_at TIMESTAMPTZ
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- WRONG — TIMESTAMP without timezone is fragile
|
|
36
|
+
CREATE TABLE events (
|
|
37
|
+
id UUID PRIMARY KEY,
|
|
38
|
+
starts_at TIMESTAMP NOT NULL, -- Breaks if session timezone isn't UTC
|
|
39
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both types use 8 bytes internally. No storage difference.
|
|
44
|
+
|
|
45
|
+
### Server Must Run in UTC
|
|
46
|
+
|
|
47
|
+
The database server, application server, and all containers must run in UTC:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# application.yml
|
|
51
|
+
datasources:
|
|
52
|
+
default:
|
|
53
|
+
connection-properties:
|
|
54
|
+
timezone: UTC
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```sql
|
|
58
|
+
-- PostgreSQL: verify
|
|
59
|
+
SHOW timezone; -- Should return 'UTC'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```dockerfile
|
|
63
|
+
# Dockerfile
|
|
64
|
+
ENV TZ=UTC
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
# Kubernetes pod spec
|
|
69
|
+
env:
|
|
70
|
+
- name: TZ
|
|
71
|
+
value: "UTC"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Backend (Kotlin)
|
|
17
77
|
|
|
18
78
|
### Always Use `Instant` — Never `LocalDateTime`
|
|
19
79
|
|
|
20
|
-
`Instant` is UTC by definition. `LocalDateTime` has no timezone info and
|
|
80
|
+
`Instant` is UTC by definition. `LocalDateTime` has no timezone info and causes bugs.
|
|
21
81
|
|
|
22
82
|
```kotlin
|
|
23
83
|
// CORRECT — Instant is always UTC
|
|
@@ -26,63 +86,48 @@ val expiresAt: Instant = Instant.now().plusSeconds(3600)
|
|
|
26
86
|
|
|
27
87
|
// WRONG — LocalDateTime has no timezone, ambiguous
|
|
28
88
|
val now: LocalDateTime = LocalDateTime.now() // What timezone? Nobody knows.
|
|
29
|
-
val expiresAt: LocalDateTime = LocalDateTime.now().plusHours(1)
|
|
30
89
|
```
|
|
31
90
|
|
|
32
|
-
###
|
|
91
|
+
### `ZonedDateTime` — Only at Computation Boundaries
|
|
92
|
+
|
|
93
|
+
Never put `ZonedDateTime` in entities, DTOs, or API payloads. It's OK for:
|
|
94
|
+
- Scheduling logic (computing "next 9 AM in user's timezone")
|
|
95
|
+
- DST-aware date arithmetic ("add 1 day" at DST boundary)
|
|
96
|
+
- Generating reports in a specific timezone
|
|
33
97
|
|
|
34
|
-
|
|
98
|
+
Always convert back to `Instant` before passing to other layers.
|
|
35
99
|
|
|
36
100
|
```kotlin
|
|
37
|
-
// CORRECT — use Instant
|
|
101
|
+
// CORRECT — entities and DTOs use Instant
|
|
38
102
|
data class UserEntity(
|
|
39
103
|
val createdAt: Instant,
|
|
40
104
|
val lastLoginAt: Instant?,
|
|
41
105
|
val subscriptionExpiresAt: Instant?
|
|
42
106
|
)
|
|
43
107
|
|
|
44
|
-
//
|
|
108
|
+
// CORRECT — ZonedDateTime only for scheduling computation
|
|
109
|
+
fun nextNotificationTime(userTimezone: String, localTime: LocalTime): Instant {
|
|
110
|
+
val zone = ZoneId.of(userTimezone)
|
|
111
|
+
val nextLocal = ZonedDateTime.now(zone).with(localTime)
|
|
112
|
+
return nextLocal.toInstant() // Convert back to Instant
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// WRONG — ZonedDateTime in entity
|
|
45
116
|
data class UserEntity(
|
|
46
|
-
val createdAt: ZonedDateTime
|
|
47
|
-
val lastLoginAt: LocalDateTime? // NO — ambiguous
|
|
117
|
+
val createdAt: ZonedDateTime // NO — keep entities in Instant
|
|
48
118
|
)
|
|
49
119
|
```
|
|
50
120
|
|
|
51
|
-
### Never Store Timezone in the Database
|
|
52
|
-
|
|
53
|
-
Don't add `timezone` columns. Don't save user timezone preferences alongside timestamps. If the frontend needs to display local time, it does the conversion itself.
|
|
54
|
-
|
|
55
|
-
```sql
|
|
56
|
-
-- CORRECT — just UTC timestamps
|
|
57
|
-
CREATE TABLE events (
|
|
58
|
-
id UUID PRIMARY KEY,
|
|
59
|
-
starts_at TIMESTAMP NOT NULL, -- UTC
|
|
60
|
-
ends_at TIMESTAMP NOT NULL, -- UTC
|
|
61
|
-
created_at TIMESTAMP DEFAULT NOW() -- UTC
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
-- WRONG — storing timezone info
|
|
65
|
-
CREATE TABLE events (
|
|
66
|
-
id UUID PRIMARY KEY,
|
|
67
|
-
starts_at TIMESTAMP NOT NULL,
|
|
68
|
-
ends_at TIMESTAMP NOT NULL,
|
|
69
|
-
timezone TEXT DEFAULT 'America/New_York' -- DON'T DO THIS
|
|
70
|
-
);
|
|
71
|
-
```
|
|
72
|
-
|
|
73
121
|
### Jackson Serialization
|
|
74
122
|
|
|
75
|
-
Jackson must serialize
|
|
123
|
+
Jackson must serialize `Instant` as ISO 8601 with the `Z` suffix:
|
|
76
124
|
|
|
77
125
|
```kotlin
|
|
78
|
-
// ObjectMapper config (usually already set)
|
|
79
126
|
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
|
80
127
|
// Output: "2024-01-15T10:30:00Z"
|
|
81
128
|
```
|
|
82
129
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Never output offsets like `+07:00` or timezone names like `Asia/Ho_Chi_Minh`.
|
|
130
|
+
Never output offsets like `+07:00` or timezone names in API responses.
|
|
86
131
|
|
|
87
132
|
---
|
|
88
133
|
|
|
@@ -100,8 +145,6 @@ Never output offsets like `+07:00` or timezone names like `Asia/Ho_Chi_Minh`.
|
|
|
100
145
|
|
|
101
146
|
### Request Bodies — Frontend Sends UTC
|
|
102
147
|
|
|
103
|
-
When the frontend sends a datetime, it MUST be UTC:
|
|
104
|
-
|
|
105
148
|
```json
|
|
106
149
|
{
|
|
107
150
|
"starts_at": "2024-01-20T09:00:00Z",
|
|
@@ -115,19 +158,16 @@ When the frontend sends a datetime, it MUST be UTC:
|
|
|
115
158
|
GET /events?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z
|
|
116
159
|
```
|
|
117
160
|
|
|
118
|
-
### No Timezone Fields in
|
|
161
|
+
### No Timezone Fields in Timestamp Payloads
|
|
162
|
+
|
|
163
|
+
Don't put timezone info alongside timestamps. The exception is user preferences (see below).
|
|
119
164
|
|
|
120
165
|
```json
|
|
121
|
-
// WRONG — timezone
|
|
122
|
-
{
|
|
123
|
-
"starts_at": "2024-01-20T09:00:00Z",
|
|
124
|
-
"timezone": "America/New_York"
|
|
125
|
-
}
|
|
166
|
+
// WRONG — timezone alongside a timestamp
|
|
167
|
+
{ "starts_at": "2024-01-20T09:00:00Z", "timezone": "America/New_York" }
|
|
126
168
|
|
|
127
|
-
// CORRECT — just UTC
|
|
128
|
-
{
|
|
129
|
-
"starts_at": "2024-01-20T09:00:00Z"
|
|
130
|
-
}
|
|
169
|
+
// CORRECT — just UTC
|
|
170
|
+
{ "starts_at": "2024-01-20T09:00:00Z" }
|
|
131
171
|
```
|
|
132
172
|
|
|
133
173
|
---
|
|
@@ -136,18 +176,10 @@ GET /events?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z
|
|
|
136
176
|
|
|
137
177
|
### Receive UTC, Convert for Display
|
|
138
178
|
|
|
139
|
-
All API responses return UTC. Convert to local only when showing to the user:
|
|
140
|
-
|
|
141
179
|
```typescript
|
|
142
|
-
//
|
|
180
|
+
// Convert at display time
|
|
143
181
|
function formatDate(utcString: string): string {
|
|
144
|
-
return new
|
|
145
|
-
// Or use Intl.DateTimeFormat for more control
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// CORRECT — with specific format
|
|
149
|
-
function formatDate(utcString: string, locale = 'en-US'): string {
|
|
150
|
-
return new Intl.DateTimeFormat(locale, {
|
|
182
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
151
183
|
dateStyle: 'medium',
|
|
152
184
|
timeStyle: 'short',
|
|
153
185
|
}).format(new Date(utcString))
|
|
@@ -156,106 +188,129 @@ function formatDate(utcString: string, locale = 'en-US'): string {
|
|
|
156
188
|
|
|
157
189
|
### Send UTC to Server
|
|
158
190
|
|
|
159
|
-
Convert local input to UTC before sending:
|
|
160
|
-
|
|
161
191
|
```typescript
|
|
162
|
-
//
|
|
163
|
-
const localDate = new Date(userInput)
|
|
164
|
-
const utcString = localDate.toISOString() // "2024-01-20T02:00:00.000Z"
|
|
165
|
-
|
|
166
|
-
await api.post('/events', {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// WRONG — sending local time string
|
|
171
|
-
await api.post('/events', {
|
|
172
|
-
startsAt: '2024-01-20T09:00:00', // No Z suffix — ambiguous!
|
|
173
|
-
})
|
|
192
|
+
// Convert local input to UTC before API call
|
|
193
|
+
const localDate = new Date(userInput)
|
|
194
|
+
const utcString = localDate.toISOString() // "2024-01-20T02:00:00.000Z"
|
|
195
|
+
|
|
196
|
+
await api.post('/events', { startsAt: utcString })
|
|
197
|
+
|
|
198
|
+
// WRONG — no Z suffix, ambiguous
|
|
199
|
+
await api.post('/events', { startsAt: '2024-01-20T09:00:00' })
|
|
174
200
|
```
|
|
175
201
|
|
|
176
|
-
###
|
|
202
|
+
### Use Browser Timezone at Render Time
|
|
177
203
|
|
|
178
|
-
|
|
204
|
+
Don't track the user's timezone in frontend state. The browser already knows it.
|
|
179
205
|
|
|
180
206
|
```typescript
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Display: UTC → user's local timezone
|
|
185
|
-
const display = formatInTimeZone(
|
|
186
|
-
new Date(apiResponse.createdAt), // UTC from API
|
|
187
|
-
Intl.DateTimeFormat().resolvedOptions().timeZone, // user's timezone
|
|
188
|
-
'MMM d, yyyy h:mm a'
|
|
189
|
-
)
|
|
207
|
+
// CORRECT — use at render time
|
|
208
|
+
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
190
209
|
|
|
191
|
-
//
|
|
192
|
-
const
|
|
210
|
+
// WRONG — storing timezone in state
|
|
211
|
+
const [timezone, setTimezone] = useState('America/New_York')
|
|
193
212
|
```
|
|
194
213
|
|
|
195
|
-
|
|
214
|
+
---
|
|
196
215
|
|
|
197
|
-
|
|
216
|
+
## When You DO Need Timezone
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
218
|
+
There are cases where storing a user's IANA timezone is correct. The rule is:
|
|
219
|
+
|
|
220
|
+
**Past events (created_at, login_at, order_placed_at):** Never store timezone. `TIMESTAMPTZ` (UTC) is enough.
|
|
221
|
+
|
|
222
|
+
**User preferences (notification time, business hours):** Store the user's IANA timezone as a separate column. Don't mix it with timestamps.
|
|
223
|
+
|
|
224
|
+
```sql
|
|
225
|
+
-- CORRECT — timezone as a user preference, not part of timestamps
|
|
226
|
+
CREATE TABLE user_preferences (
|
|
227
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
228
|
+
user_id UUID NOT NULL,
|
|
229
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA timezone: 'America/New_York'
|
|
230
|
+
notification_time TEXT NOT NULL DEFAULT '09:00', -- local time, not a timestamp
|
|
231
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
232
|
+
);
|
|
202
233
|
|
|
203
|
-
|
|
204
|
-
|
|
234
|
+
-- Then compute UTC fire time dynamically in code:
|
|
235
|
+
-- nextFire = ZonedDateTime.of(today, LocalTime.parse("09:00"), ZoneId.of("America/New_York")).toInstant()
|
|
205
236
|
```
|
|
206
237
|
|
|
238
|
+
**Why dynamic computation?** Because DST shifts change the UTC offset. "9 AM New York" is `14:00 UTC` in winter but `13:00 UTC` in summer. Storing a fixed UTC value would drift by an hour.
|
|
239
|
+
|
|
240
|
+
**Never use fixed offsets as timezone identifiers.** `+05:30` is an offset, not a timezone. It changes with DST. Use IANA names: `Asia/Kolkata`, `America/New_York`.
|
|
241
|
+
|
|
207
242
|
---
|
|
208
243
|
|
|
209
|
-
##
|
|
244
|
+
## Microservices
|
|
210
245
|
|
|
211
|
-
###
|
|
246
|
+
### Inter-Service Communication
|
|
212
247
|
|
|
213
|
-
|
|
248
|
+
All service-to-service datetime fields use ISO 8601 UTC, same as external APIs.
|
|
214
249
|
|
|
215
|
-
|
|
216
|
-
-- CORRECT
|
|
217
|
-
created_at TIMESTAMP DEFAULT NOW() -- NOW() returns UTC in a UTC-configured server
|
|
250
|
+
### Event Streaming (Kafka, RabbitMQ)
|
|
218
251
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
252
|
+
- Use epoch-based types: Avro `timestamp-millis`, Protobuf `google.protobuf.Timestamp`
|
|
253
|
+
- These are UTC by definition — no timezone ambiguity
|
|
254
|
+
- Document in your schema that all timestamps are UTC epoch
|
|
222
255
|
|
|
223
|
-
###
|
|
256
|
+
### Logging
|
|
224
257
|
|
|
225
|
-
|
|
258
|
+
All services must log in UTC. If services in different timezones log in local time, correlating logs across services is a nightmare.
|
|
226
259
|
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
default:
|
|
231
|
-
connection-properties:
|
|
232
|
-
timezone: UTC
|
|
260
|
+
```xml
|
|
261
|
+
<!-- logback.xml — force UTC -->
|
|
262
|
+
<timestamp key="timestamp" datePattern="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" timeReference="UTC"/>
|
|
233
263
|
```
|
|
234
264
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
265
|
+
### Distributed Tracing
|
|
266
|
+
|
|
267
|
+
OpenTelemetry spans use nanosecond UTC timestamps internally. No action needed — but make sure NTP is configured on all nodes. Clock skew (not timezone) is the bigger concern.
|
|
268
|
+
|
|
269
|
+
### Cron / Scheduler Jobs
|
|
270
|
+
|
|
271
|
+
Cron expressions are timezone-sensitive. DST transitions can cause jobs to fire twice, or not at all, in the 1-3 AM window.
|
|
272
|
+
|
|
273
|
+
```kotlin
|
|
274
|
+
// CORRECT — schedule in UTC to avoid DST issues
|
|
275
|
+
@Scheduled(cron = "0 0 14 * * *", zone = "UTC") // 2 PM UTC, not "2 PM local"
|
|
276
|
+
fun dailyDigest() { ... }
|
|
277
|
+
|
|
278
|
+
// If the job MUST fire at local wall-clock time, use IANA timezone explicitly:
|
|
279
|
+
@Scheduled(cron = "0 0 9 * * *", zone = "America/New_York") // 9 AM New York, DST-aware
|
|
280
|
+
fun morningNotification() { ... }
|
|
238
281
|
```
|
|
239
282
|
|
|
283
|
+
Avoid scheduling jobs in the 1:00-3:00 AM local time window for any timezone with DST.
|
|
284
|
+
|
|
285
|
+
### IANA Timezone Database Updates
|
|
286
|
+
|
|
287
|
+
Governments change DST rules. Your JVM and OS timezone databases need updating. If you run long-lived JVMs, update the JDK or use the TZUpdater tool.
|
|
288
|
+
|
|
240
289
|
---
|
|
241
290
|
|
|
242
291
|
## Quick Reference
|
|
243
292
|
|
|
244
293
|
| Layer | Type | Format | Example |
|
|
245
294
|
|-------|------|--------|---------|
|
|
246
|
-
| Database | Column type | `
|
|
295
|
+
| Database | Column type | `TIMESTAMPTZ` | `2024-01-15 10:30:00+00` |
|
|
247
296
|
| Backend (Kotlin) | Property type | `Instant` | `Instant.now()` |
|
|
248
297
|
| API JSON | String | ISO 8601 + Z | `"2024-01-15T10:30:00Z"` |
|
|
249
298
|
| Frontend (receive) | Parse | `new Date(utcString)` | `new Date("2024-01-15T10:30:00Z")` |
|
|
250
|
-
| Frontend (display) | Format | `
|
|
299
|
+
| Frontend (display) | Format | `Intl.DateTimeFormat` | `"Jan 15, 2024, 5:30 PM"` |
|
|
251
300
|
| Frontend (send) | Serialize | `toISOString()` | `"2024-01-15T10:30:00.000Z"` |
|
|
301
|
+
| Events (Kafka) | Type | epoch millis | `1705312200000` |
|
|
302
|
+
| Cron jobs | Zone | IANA or UTC | `zone = "UTC"` |
|
|
303
|
+
| User preference | Column | IANA timezone | `America/New_York` |
|
|
252
304
|
|
|
253
305
|
## What NOT to Do
|
|
254
306
|
|
|
307
|
+
- Don't use `TIMESTAMP` without timezone — use `TIMESTAMPTZ`
|
|
255
308
|
- Don't use `LocalDateTime` in Kotlin — use `Instant`
|
|
256
|
-
- Don't
|
|
257
|
-
- Don't
|
|
258
|
-
- Don't
|
|
309
|
+
- Don't put `ZonedDateTime` in entities or DTOs
|
|
310
|
+
- Don't use fixed offsets (`+05:30`) as timezone identifiers — use IANA names
|
|
311
|
+
- Don't store timezone alongside timestamps for past events
|
|
259
312
|
- Don't convert to local time on the backend — that's the frontend's job
|
|
260
|
-
- Don't assume a timezone — let the browser handle it
|
|
261
313
|
- Don't format dates on the server for display — return UTC, let the client format
|
|
314
|
+
- Don't schedule cron jobs in the 1-3 AM DST window
|
|
315
|
+
- Don't assume the host timezone is UTC — set `TZ=UTC` in containers
|
|
316
|
+
- Don't log in local time — all services log UTC
|
package/rules/database/SCHEMA.md
CHANGED
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
- **No ON DELETE CASCADE** — Handle deletions in application
|
|
16
16
|
- **TEXT not VARCHAR** — Always use TEXT for strings
|
|
17
17
|
- **UUID primary keys** — `uuid_generate_v4()`
|
|
18
|
-
- **Soft delete** — Use `deleted_at
|
|
18
|
+
- **Soft delete** — Use `deleted_at TIMESTAMPTZ`, never hard delete records
|
|
19
19
|
- Standard columns: `id`, `created_at`, `updated_at`, `deleted_at`
|
|
20
20
|
|
|
21
21
|
### Data Type Standards
|
|
22
22
|
- Strings: TEXT (not VARCHAR)
|
|
23
23
|
- IDs: UUID
|
|
24
|
-
- Dates:
|
|
24
|
+
- Dates: TIMESTAMPTZ (all timestamps are UTC, see `TIMEZONE.md`)
|
|
25
25
|
- Booleans: BOOLEAN
|
|
26
26
|
- Flexible data: JSONB
|
|
27
27
|
- IP addresses: INET
|
|
@@ -107,9 +107,9 @@ CREATE TABLE products (
|
|
|
107
107
|
id UUID PRIMARY KEY,
|
|
108
108
|
name TEXT NOT NULL,
|
|
109
109
|
status TEXT DEFAULT 'active',
|
|
110
|
-
created_at
|
|
111
|
-
updated_at
|
|
112
|
-
deleted_at
|
|
110
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
111
|
+
updated_at TIMESTAMPTZ,
|
|
112
|
+
deleted_at TIMESTAMPTZ
|
|
113
113
|
);
|
|
114
114
|
```
|
|
115
115
|
|
|
@@ -2,37 +2,18 @@
|
|
|
2
2
|
# Copy this file to .spartan/build.yaml in your project and customize.
|
|
3
3
|
# All fields are optional. Omit a field to use the default.
|
|
4
4
|
|
|
5
|
-
# --- Worktree ---
|
|
6
|
-
|
|
7
|
-
# Worktree isolation (default: true)
|
|
8
|
-
# When true, /spartan:build creates a git worktree per feature.
|
|
9
|
-
# Each terminal gets its own directory, branch, and PR — no conflicts.
|
|
10
|
-
# Set to false to use simple git checkout (single terminal only).
|
|
11
|
-
worktree: true
|
|
12
|
-
|
|
13
5
|
# Branch prefix (default: "feature")
|
|
14
6
|
# Controls the branch name: [prefix]/[slug] (e.g., "feature/user-auth")
|
|
15
7
|
branch-prefix: "feature"
|
|
16
8
|
|
|
17
|
-
# Extra directories to symlink into worktrees
|
|
18
|
-
# Gitignored dirs (.planning, .memory, .handoff, .spartan) are always symlinked.
|
|
19
|
-
# Add more here if your project has other shared gitignored dirs.
|
|
20
|
-
# worktree-symlinks:
|
|
21
|
-
# - .env
|
|
22
|
-
# - config/local
|
|
23
|
-
|
|
24
|
-
# --- Build stages ---
|
|
25
|
-
|
|
26
9
|
# Max review-fix cycles before asking the user (default: 3)
|
|
27
10
|
# max-review-rounds: 3
|
|
28
11
|
|
|
29
12
|
# Stages to skip (use carefully — most stages exist for a reason)
|
|
30
|
-
# Valid: spec, design, plan,
|
|
31
|
-
# Note: review can never
|
|
13
|
+
# Valid: spec, design, plan, ship
|
|
14
|
+
# Note: review can never be skipped — it's always enforced
|
|
32
15
|
# skip-stages: []
|
|
33
16
|
|
|
34
|
-
# --- Custom prompts ---
|
|
35
|
-
|
|
36
17
|
# Custom instructions injected into build stages.
|
|
37
18
|
# Claude reads these alongside the built-in workflow instructions.
|
|
38
19
|
# Use this to add project-specific rules, naming conventions, or checklists.
|