@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.
@@ -10,7 +10,7 @@
10
10
  "name": "spartan-ai-toolkit",
11
11
  "description": "5 workflows, 68 commands, 21 rules, 28 skills, 9 agents — organized in 12 packs with dependencies",
12
12
  "source": "./toolkit",
13
- "version": "1.9.1"
13
+ "version": "1.9.2"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spartan-ai-toolkit",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Engineering discipline layer for Claude Code — 5 workflows, 68 commands, 21 rules, 28 skills, 9 agents organized in 12 packs",
5
5
  "author": {
6
6
  "name": "Khoa Tran",
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
+ 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 Gate 2 Gate 3 Spawn agent Loop Gate 4
20
- Gate fix until OK
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 detected → Per feature: Spec/Design/Plan → ImplementReview Agent Fix → Ship
25
- │ │ │ │ │
26
- .planning/ read epic fill gaps if needed parallel by Spawn agent Loop one PR
27
- epics/ dependency fix until OK
24
+ Context → Epic → Per feature: Spec/Design/Plan → WorktreeImplementReview → Ship
25
+ │ │ │ │
26
+ .planning/ read epic fill gaps git worktree parallel Loop one PR
27
+ epics/ .worktrees/ by dep
28
28
 
29
- PARALLEL BUILDS (automaticeach build in its own worktree):
29
+ PARALLEL (multiple terminals automatic):
30
30
 
31
- /spartan:build auth → worktree .claude/worktrees/feature-auth/ → PR #1
32
- /spartan:build payments → worktree .claude/worktrees/feature-payments/ → PR #2
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 & Enter Worktree
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`, `review`, `ship` |
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
- ### Verify workspace
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
- Confirm you're in the right place:
314
+ Use `branch-prefix` from config (default: `feature`). Generate a slug from the feature name.
365
315
 
366
316
  ```bash
367
- pwd
368
- git branch --show-current
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
- **If worktree is enabled (default):** You should already be in a worktree from the "FIRST" step. If not, go back and create one.
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
- **If `worktree: false` in config:** Create a branch now:
337
+ # Gitignore worktrees dir
338
+ grep -qxF '.worktrees/' "$MAIN_REPO/.gitignore" 2>/dev/null || echo '.worktrees/' >> "$MAIN_REPO/.gitignore"
374
339
 
375
- ```bash
376
- git checkout -b feature/[slug]
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
- **If worktree enabled (default):** You should already be in a worktree from the "FIRST" step.
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
- pwd
842
- git branch --show-current
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. Step 0.5 checks for `.handoff/` files and existing `.planning/` artifacts
913
- 2. Determine which stage was completed last:
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
- 3. Show the user: "Resuming from [stage]. Here's what was done: [summary]."
918
- 4. Continue from that point.
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
- - **Worktree by default.** The first step is `EnterWorktree` (unless `worktree: false` in `.spartan/build.yaml`). This lets multiple terminals run `/spartan:build` simultaneously. If already in a worktree, skip. If `EnterWorktree` fails, fall back to manual worktree.
942
- - **Build config is `.spartan/build.yaml`.** Controls worktrees, branch prefix, max review rounds, skip stages, custom prompts per stage, and extra worktree symlinks. All fields optional.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c0x12c/spartan-ai-toolkit",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Engineering discipline layer for AI coding agents — commands, rules, skills, agents, and packs for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 — and only for display.
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 (UTC) → Frontend receives (UTC) → Display (local)
11
- Frontend sends (UTC) ← Input (local → UTC)
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
- ## Backend
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 leads to bugs.
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
- ### Never Use `ZonedDateTime` in Business Logic
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
- `ZonedDateTime` is only for converting when absolutely needed (e.g., generating a report for a specific timezone). Don't pass it between layers.
98
+ Always convert back to `Instant` before passing to other layers.
35
99
 
36
100
  ```kotlin
37
- // CORRECT — use Instant everywhere
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
- // WRONG — ZonedDateTime in entities/DTOs
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, // NO — carries timezone baggage
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 all `Instant` fields as ISO 8601 with the `Z` (UTC) suffix. This should be configured globally:
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
- Every datetime field in API JSON looks like: `"2024-01-15T10:30:00Z"`
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 Request or Response
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 info in API
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, frontend handles display
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
- // CORRECT — convert at display time
180
+ // Convert at display time
143
181
  function formatDate(utcString: string): string {
144
- return new Date(utcString).toLocaleString()
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
- // CORRECT convert to UTC before API call
163
- const localDate = new Date(userInput) // user picks "Jan 20, 2024 9:00 AM"
164
- const utcString = localDate.toISOString() // "2024-01-20T02:00:00.000Z" (if user is UTC+7)
165
-
166
- await api.post('/events', {
167
- startsAt: utcString, // Always UTC
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
- ### Date Libraries
202
+ ### Use Browser Timezone at Render Time
177
203
 
178
- If using a date library (date-fns, dayjs, luxon), still follow the same pattern:
204
+ Don't track the user's timezone in frontend state. The browser already knows it.
179
205
 
180
206
  ```typescript
181
- // date-fns example
182
- import { formatInTimeZone } from 'date-fns-tz'
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
- // Send: local UTC
192
- const utc = new Date(localInput).toISOString()
210
+ // WRONG storing timezone in state
211
+ const [timezone, setTimezone] = useState('America/New_York')
193
212
  ```
194
213
 
195
- ### Never Store Timezone in Frontend State
214
+ ---
196
215
 
197
- Don't track the user's timezone in state or send it to the backend. The browser already knows the timezone — use it at render time.
216
+ ## When You DO Need Timezone
198
217
 
199
- ```typescript
200
- // WRONG — tracking timezone in state
201
- const [timezone, setTimezone] = useState('America/New_York')
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
- // CORRECT use browser timezone at render time
204
- const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
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
- ## Database
244
+ ## Microservices
210
245
 
211
- ### TIMESTAMP Without Timezone
246
+ ### Inter-Service Communication
212
247
 
213
- Use `TIMESTAMP` (not `TIMESTAMPTZ`). The value is always UTC. No timezone info needed.
248
+ All service-to-service datetime fields use ISO 8601 UTC, same as external APIs.
214
249
 
215
- ```sql
216
- -- CORRECT
217
- created_at TIMESTAMP DEFAULT NOW() -- NOW() returns UTC in a UTC-configured server
250
+ ### Event Streaming (Kafka, RabbitMQ)
218
251
 
219
- -- ALSO ACCEPTABLE (PostgreSQL stores both as UTC internally)
220
- created_at TIMESTAMPTZ DEFAULT NOW()
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
- ### Server Must Be Configured for UTC
256
+ ### Logging
224
257
 
225
- The database server and application server must run in UTC:
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
- ```yaml
228
- # application.yml
229
- datasources:
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
- ```sql
236
- -- PostgreSQL: verify server timezone
237
- SHOW timezone; -- Should return 'UTC'
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 | `TIMESTAMP` | `2024-01-15 10:30:00` |
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 | `toLocaleString()` | `"Jan 15, 2024, 5:30 PM"` (UTC+7) |
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 store timezone names or offsets in the database
257
- - Don't send timezone info in API requests or responses
258
- - Don't use `TIMESTAMPTZ` thinking it "stores the timezone" PostgreSQL converts everything to UTC anyway
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
@@ -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 TIMESTAMP`, never hard delete records
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: TIMESTAMP
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 TIMESTAMP DEFAULT NOW(),
111
- updated_at TIMESTAMP,
112
- deleted_at TIMESTAMP
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, review, ship
31
- # Note: review can never actually be skipped — it's always enforced
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.