# Sysadmin Chronicles — Repo-Aware Implementation Plan

**Generated from:** Prompt 05 repo inspection  
**Date:** 2026-05-01  
**Scope:** Integrating the redesigned quest/story system into the existing codebase without breaking current content or runtime

---

## 1. Current Architecture Summary

### 1.1 Where quest logic lives

**Primary service:** `server/src/services/QuestEngine.js`

- Stores quest entries in a `Map<questId, entry>` where entry = `{ state, started_at, completed_at, branch_id }`
- States: `locked | active | completed | failed`
- Activation: checks `unlock_requirements` against current `world_flags` in save state
- Completion: called by `TicketService.markComplete()` after branch validation succeeds
- Initial quests (no `unlock_requirements`) auto-activate on first load

**Orchestration:** `server/src/services/TicketService.js`

- `markComplete(ticketId)` is the central transaction:
  1. Runs `ValidationEngine.resolveBranch(quest)` to find winning branch
  2. Applies `branch.world_flags` to save state
  3. Calls `trustSystem.adjust(branch.trust_delta)`
  4. Calls `questEngine.complete()`
  5. Sends follow-up dialogue email if trust delta ≤ 0
  6. Activates follow-up ticket via `_activateFollowUpTicket()`
  7. Emits `ticket:completed` event

There is no `BehaviorTracker`, no `NarrativePhaseTracker`, no `AccessLevelSystem`, no `EndingEvaluator`. These are fully absent.

### 1.2 Where quest data lives

- Quest JSON: `content/quests/Q*.json` — 8 quests authored (Q001–Q008)
- Tickets: `content/tickets/T*.json` — 8+ tickets, linked 1:1 to quests via `linked_quest`
- Dialogue: `content/dialogue/*.json` — per-character, per-quest reaction files
- Incidents: `content/incidents/I*.json` — recurring consequence definitions (3 authored)
- Pressure profiles: `content/pressure_profiles/*.json` — time-based escalation sequences (4 authored)
- World flags registry: `content/world_flags/world_flags.json` — canonical flag declarations
- Trust unlocks: `content/progression/trust_unlocks.json` — 5 unlock thresholds defined
- VM profiles: `content/vm_profiles/*.json` — workstation, web_server, build_machine

**Missing content subdirectories:** There is no `content/narrative_phases/`, no `content/behavior_profiles/`, no `content/endings/`, no `content/hidden_hooks/`. These need to be created.

### 1.3 How quests start and complete

1. Server loads via `contentLoader.load()` then initializes services from `saveState.get()`
2. `QuestEngine.initialize()` restores quest state from save; auto-activates quests with no requirements
3. `TicketService.initialize()` cross-references quest state to activate/resolve ticket entries
4. Player submits a `POST /api/tickets/:id/complete` request
5. `TicketService.markComplete()` runs full validation → branch resolution → state mutation → events
6. Follow-up ticket activates if specified on the winning branch; next quest auto-starts

### 1.4 How player state is saved

**File:** `~/.local/share/sysadmin-chronicles/save.json` (configurable via `SAVE_DIR`)  
**Schema version:** 2  
**Current top-level keys:**
```
schema_version, created_at, last_saved, trust, shift_number,
shift_started_at, world_flags, progression, quests, tickets,
mail, certifications, current_shift_stats, shift_history,
pressure, incidents, sage, player_portrait
```

`SaveState.set(partial)` does shallow-merge with special handling for arrays and plain objects. Writes are queued and serialized.

**Missing keys:** `behavior` (curiosity/obedience/risk), `narrative_phase`, `suspicion`, `access_level`, `hidden_hooks_discovered`. These must be added with defaults at `schema_version: 3`.

### 1.5 How UI displays quest information

Quest display is minimal. The `TicketsPanel.svelte` component shows:
- Ticket ID, subject, priority badge, status
- A "Mark Complete" button that triggers `POST /api/tickets/:id/complete`
- Linked quest ID as static text in the detail view
- No quest progress, no objectives display, no narrative phase, no behavior indicators

`HeaderBar.svelte` shows:
- Trust score (as text label: Probationary/Settling In/Reliable/Entrusted) and meter bar
- Shift number and countdown
- Certification count

There is no behavior dashboard, no narrative phase indicator, no access level display, no hidden hook discovery log. The `/api/state` route does expose `worldFlags` and `progression` to the frontend but neither is currently rendered.

### 1.6 How branch resolution works

`ValidationEngine.resolveBranch(quest)` iterates branches sorted by descending priority, runs each branch's `validation` rule tree against live VM state via SSH, and returns the first passing branch. All validation runs real SSH commands against the QEMU/libvirt VMs. No mocking. The engine supports: `and`, `or`, `not`, `file_exists/absent/contains/mode/owner`, `service_state/enabled`, `process_running/user`, `port_listening`, `package_installed`, `mount_present`, `disk_usage_below/above`, `command_assert`.

---

## 2. Spec Preservation Analysis

For each SPEC_LOCK.md requirement:

| Spec requirement | Status | Notes |
|---|---|---|
| Narrative spine (6 phases) | **Missing** | No phase field on quests; no phase tracker in runtime |
| Quest must declare `narrative_phase` | **Missing** | Not in current quest schema |
| Quest must declare `behavior_impact` | **Missing** | Not in current schema; spec defines branch-level overrides |
| `curiosity` tracking | **Missing** | No BehaviorTracker service |
| `obedience` tracking | **Missing** | No BehaviorTracker service |
| `risk` tracking | **Missing** | No BehaviorTracker service |
| `trust` preserved | **Already supported** | TrustSystem.js is complete and robust |
| `suspicion` as management attention | **Missing** | No suspicion variable; concept is not tracked |
| `trust_delta` on branches | **Already supported** | Fully implemented in TicketService.markComplete |
| `world_flags` | **Already supported** | Full registry, branch application, persistence |
| Access system: `basic_user → sudo → root` | **Partially supported** | ProgressionSystem tracks `unlocked_access` strings but doesn't use the three-tier access model; no concept of `basic_user/sudo/root` as named levels |
| Trust gates access | **Already supported** | `trust_unlocks.json` → ProgressionSystem |
| Suspicion gates access | **Missing** | Suspicion doesn't exist as a tracked variable |
| Boss/management pressure phase scaling | **Partially supported** | `pressure_profiles` and `IncidentScheduler` can escalate tickets and send emails; but pressure is keyed per-quest, not per narrative phase; there is no phase-aware boss behavior model |
| Hidden hook system (no markers, optional) | **Missing** | No hidden hook schema, no discovery state, no tracker |
| Quest generation constraints (reuse systems) | **Already supported** — design intent preserved | |
| Difficulty scaling by phase | **Missing** | No phase-aware difficulty or hint logic |
| Endings: 4 types, behavior-driven | **Missing** | No EndingEvaluator; no ending content authored |
| Endings emerge from accumulated state | **Missing** | No ending evaluation logic |
| Follow-up ticket/incident chaining | **Already supported** | TicketService + IncidentScheduler |
| Observed-VM-state validation | **Already supported** | ValidationEngine is complete |
| Clue fingerprints | **Already supported** | Documented and validated |
| Baseline snapshots + prep scripts | **Already supported** | tools/vm/quest-prep/ + seed-vms.sh |
| Debug/dev tools for narrative state | **Missing** | Only `validate-content.js`; no debug route for behavior/phase state |

**Risk items:**
- `ShiftReviewService.js` hardcodes `reviewer: 'Priya Kapoor'` and sends from `p.kapoor@axiomworks.internal`. This must be corrected to Priya Nair / `p.nair@axiomworks.internal` before shipping any new content.
- `EmailService.js` CHARACTER_EMAILS has `priya: 'Priya Kapoor <p.kapoor@axiomworks.internal>'`. Same fix required.
- `content/tickets/T007.json` may still reference the old Priya name (noted in CHARACTERS.md).
- `content/docs/onboarding.json` may reference "Priya Kapoor" or "Priya Singh".

---

## 3. Gap Analysis

### Narrative phases
**Gap:** No `narrative_phase` field on quest JSON. No runtime tracker. No API endpoint to query current phase. No phase-driven behavior changes (ticket wording hints, clue obviousness, boss mode).

### Behavior tracking (curiosity / obedience / risk)
**Gap:** Completely absent. No service, no save state key, no UI, no branch-level behavior deltas applied at completion time.

### Access progression (basic_user / sudo / root)
**Gap:** ProgressionSystem tracks opaque `unlocked_access` strings (like `"sudo:web_server:systemctl"`). The spec requires a named three-tier model. Currently trust gates access but suspicion does not.

### Boss/management pressure (phase-scaled)
**Gap:** `IncidentScheduler` applies pressure per active quest, not per phase. There is no phase-keyed pressure mode. Kowalski is not implemented as an active character in any ticket or dialogue.

### Hidden hooks
**Gap:** No `hidden_hook` field in quest JSON. No discovery state in save. No mechanism to record what the player found. The world_flags system *could* be used for discovery state (e.g., `hidden:dale_ssh_key_found`) but nothing does this yet.

### Endings
**Gap:** Fully absent. No ending content, no EndingEvaluator, no condition set, no trigger. The four endings (corporate_loop, burnout, exposure, chaos) have no authored trigger criteria.

### Debug tooling
**Gap:** Only `validate-content.js` for content authoring. No in-game or dev-API route to inspect: current behavior scores, narrative phase, suspicion level, hidden hooks discovered, ending trajectory.

### Validation of new schema fields
**Gap:** `validate-content.js` does not check `narrative_phase`, `behavior_impact`, `hidden_hook`, `linux_concepts`, or `access_requirements`. New content will not be validated against these fields until the tool is updated.

### Name correction — Priya Nair
**Gap (immediate):** Three files hardcode the wrong canonical name. Must be fixed before new content ships.

---

## 4. Minimal-Change Implementation Plan

**Philosophy:** Extend the existing system. Do not replace working services. New functionality adds new services and new save state keys. Existing content is not broken. New fields are optional until all content is updated.

---

### Task 1 — Repo inspection (complete, no edits)

Inspect the full codebase to confirm architecture, identify all files that reference Priya Kapoor, and establish baseline for subsequent tasks.

**Acceptance criteria:** Authored plan with confirmed file paths and line numbers.

---

### Task 2 — Extend quest schema and validation tooling

**What changes:**
- Add `narrative_phase`, `behavior_impact`, `hidden_hook`, `linux_concepts`, `systems_used`, `failure_conditions`, `access_requirements` as optional fields to the quest JSON schema
- Update `validate-content.js` to: warn when `narrative_phase` is absent, validate `narrative_phase` against the 6-value enum, check `behavior_impact` structure if present, validate `hidden_hook` shape if present, check `access_requirements.minimum_access` against known VM IDs
- Add the 6 phase values as a declared constant in the validator

**Files changed:** `tools/content/validate-content.js`  
**Risk:** Low — additive only; existing quests with no new fields pass with warnings

---

### Task 3 — Behavior tracking service

**What changes:**
- New service: `server/src/services/BehaviorTracker.js`
  - Tracks `curiosity`, `obedience`, `risk` as numeric values (0–100, start 50)
  - Method: `apply(behaviorImpact)` — adds branch-level deltas
  - Method: `getSnapshot()` — returns `{ curiosity, obedience, risk }`
  - Method: `initialize(state)` — loads from save state
  - Persists via `saveState.set({ behavior: ... })`
  - Emits `behavior:changed` event on change
- Add `behavior` key to `SaveState._defaultState()` with schema_version bump to 3
- `SaveState._applyDefaults()` already merges new keys safely — no migration needed for existing saves
- Wire `behaviorTracker.initialize(state)` into `server/src/index.js` `initializeServices()`
- Call `behaviorTracker.apply(branch.behavior_impact?.[branch.id] ?? branch.behavior_impact?.default ?? {})` inside `TicketService.markComplete()` after branch is selected

**Files changed:** `server/src/services/BehaviorTracker.js` (new), `server/src/services/SaveState.js`, `server/src/index.js`, `server/src/services/TicketService.js`  
**Risk:** Low — additive; behavior impact fields are optional in quest JSON so existing quests don't crash

---

### Task 4 — Narrative phase tracker

**What changes:**
- New service: `server/src/services/NarrativePhaseTracker.js`
  - Maintains current phase as one of: `normal_work | unease | suspicion | investigation | conflict | resolution`
  - Phase is derived from completed quests: determined by the highest-phase quest completed so far
  - Method: `getPhase()` — returns current string
  - Method: `advance(questId)` — checks the completed quest's `narrative_phase` field and updates phase if it is higher on the spine
  - Method: `initialize(state)` — restores from `state.narrative_phase`
  - Persists via `saveState.set({ narrative_phase: ... })`
  - Emits `narrative:phase_changed` event
- Add `narrative_phase` key to `SaveState._defaultState()` with value `'normal_work'`
- Call `narrativePhaseTracker.advance(questId)` inside `QuestEngine.complete()` after state mutation
- Expose `narrativePhase` in `/api/state` response (`server/src/routes/state.js`)

**Files changed:** `server/src/services/NarrativePhaseTracker.js` (new), `server/src/services/SaveState.js`, `server/src/services/QuestEngine.js`, `server/src/routes/state.js`, `server/src/index.js`  
**Risk:** Low — additive; quests without `narrative_phase` field default to `normal_work`, which never advances the tracker

---

### Task 5 — Hidden hook discovery state

**What changes:**
- New save state key: `hidden_hooks_discovered` — array of hook IDs (strings)
- `SaveState._defaultState()` adds `hidden_hooks_discovered: []`
- New service: `server/src/services/HiddenHookTracker.js`
  - Method: `discover(hookId)` — adds hookId to discovered list, persists, emits `hidden_hook:discovered`
  - Method: `isDiscovered(hookId)` — boolean check
  - Method: `getDiscovered()` — returns array
  - Method: `initialize(state)` — restores from save
- New API route (dev/admin only): `GET /api/debug/hidden-hooks` — returns discovered hooks and all declared hooks from quest JSON
- `HiddenHook` discovery is triggered by the player finding specific files, users, or cron entries via terminal commands — the prep script seeds the artifact; the hook is discovered via a new optional validation check called on terminal activity, OR it can be registered as a special objective with `check_mode: "passive"` and `behavior_impact` of `curiosity: +2`

**Design note:** The simplest integration is: hidden hook discovery = passive objective with `hidden: true` flag. When a `hidden: true` objective validates, `HiddenHookTracker.discover()` is called instead of updating quest progress. This reuses the existing ValidationEngine without a new runtime mechanism.

**Files changed:** `server/src/services/HiddenHookTracker.js` (new), `server/src/services/SaveState.js`, `server/src/index.js`, `server/src/routes/state.js`  
**Risk:** Low — discovery mechanism is opt-in per quest

---

### Task 6 — Access level system

**What changes:**
- Extend `ProgressionSystem` with a named three-tier concept:
  - `basic_user` — default, always available
  - `sudo` — granted by trust threshold (already exists as `unlocked_access` strings, just unnamed)
  - `root` — granted at higher trust threshold
- Add `content/progression/access_levels.json` — defines access level thresholds (trust + suspicion gates)
- Add `suspicion` key to `SaveState._defaultState()` with value `0`
- Add `suspicion` tracking to `BehaviorTracker` (or a thin `SuspicionTracker`) — updated whenever `risk` behavior delta fires
- Suspicion threshold: if `suspicion >= 70`, revoke certain access levels (mirror of trust revoke logic)
- Add `access_level` computed field to `/api/state` response: `basic_user | sudo | root` based on current `unlocked_access` set
- `trust_unlocks.json` entries can remain as-is; the `access_level` label is a derived label for UI/debug use

**Files changed:** `server/src/services/ProgressionSystem.js` (extend with `getAccessLevel()` helper), `server/src/services/SaveState.js`, `server/src/routes/state.js`, `content/progression/access_levels.json` (new)  
**Risk:** Medium — `suspicion` as an access gate requires careful tuning; start with suspicion as display-only, gate access only in Task 7 when boss pressure is wired

---

### Task 7 — Boss/management pressure (phase-scaled)

**What changes:**
- Add `content/pressure_profiles/kowalski_phase_*.json` — 6 phase-keyed boss pressure profiles:
  - Phase 1: Annoying (routine status email)
  - Phase 2: Dismissive (reply-all on a ticket)
  - Phase 3: Suspicious (access review CC)
  - Phase 4: Monitoring (meeting scheduled)
  - Phase 5: Interfering (access restriction trigger)
  - Phase 6: Outcome-dependent (depends on world flags)
- Extend `IncidentScheduler` to also process a `phase_pressure` tracker:
  - When `narrativePhaseTracker.getPhase()` changes, activate the matching phase pressure profile
  - Phase pressure escalation steps are sent as `emailService.send()` from Kowalski or Priya
- Add `follow_up_mail` field support to incident escalation steps (already possible via `emailService.send()`)
- Restrict access on phase 5 via `progressionSystem.revokeUnlock()` driven by a world flag set by phase 5 pressure

**Files changed:** `server/src/services/IncidentScheduler.js` (extend), `server/src/services/NarrativePhaseTracker.js` (emit event on change), `content/pressure_profiles/` (new files)  
**Risk:** Medium — phase pressure interacts with trust/suspicion; test pressure escalation in isolation before linking to access revoke

---

### Task 8 — Ending evaluation

**What changes:**
- New service: `server/src/services/EndingEvaluator.js`
  - Evaluates the active ending route from world state at any time (not just at game end)
  - Method: `evaluate()` — returns the current ending label (`corporate_loop | burnout | exposure | chaos`) and a confidence object
  - Criteria (derived from SPEC_LOCK.md):
    - `exposure`: high curiosity, narrative_phase reached `investigation` or `conflict`, hidden hooks discovered ≥ N
    - `corporate_loop`: high obedience, low curiosity, trust > 70, few hidden hooks discovered
    - `burnout`: low obedience AND low curiosity, trust medium-low, many unresolved incidents
    - `chaos`: high risk, many negative trust_deltas, suspicion high, destructive world flags present
  - Method: `checkTrigger()` — called at quest completion; if conditions are fully met and phase = `resolution`, fires `ending:triggered` event
- New API endpoint: `GET /api/debug/ending` — returns current ending trajectory (dev only)
- The ending trigger should NOT be a single button. `EndingEvaluator` is called passively on `quest:completed` events.

**Files changed:** `server/src/services/EndingEvaluator.js` (new), `server/src/index.js`, `server/src/routes/state.js`  
**Risk:** Medium — ending criteria tuning requires extensive playtesting; ship as observable-only first, gate actual ending cutscene/screen behind a separate Task 10 content work

---

### Task 9 — Debug/dev tools

**What changes:**
- New route file: `server/src/routes/debug.js` — only active when `NODE_ENV !== 'production'`
  - `GET /api/debug/state` — full save state dump
  - `GET /api/debug/behavior` — current behavior snapshot (curiosity/obedience/risk/suspicion)
  - `GET /api/debug/phase` — current narrative phase
  - `GET /api/debug/ending` — current ending trajectory
  - `GET /api/debug/hidden-hooks` — discovered + undiscovered hooks
  - `POST /api/debug/set-behavior` — override behavior variables (for testing branches)
  - `POST /api/debug/set-phase` — force a narrative phase (for testing phase-specific pressure)
  - `POST /api/debug/discover-hook/:id` — manually fire hook discovery (for testing)
- Wire debug router into `server/src/index.js` behind `NODE_ENV` guard
- Add a minimal debug panel to the frontend (dev only): collapsible overlay showing behavior, phase, ending trajectory — controlled by `?debug=1` query param

**Files changed:** `server/src/routes/debug.js` (new), `server/src/index.js`, `frontend/src/App.svelte` (conditional debug panel), `frontend/src/components/DebugPanel.svelte` (new)  
**Risk:** Low — debug routes are gated; frontend panel is conditional

---

### Task 10 — Content integration

**What changes:**
- Add new fields to all 8 existing quests: `narrative_phase`, `behavior_impact`, `hidden_hook`, `linux_concepts`, `failure_conditions`, `access_requirements`
- Fix Priya's name in: `server/src/services/ShiftReviewService.js`, `server/src/services/EmailService.js`, `content/tickets/T007.json`, `content/docs/onboarding.json`
- Register any new world flags needed by the new fields in `content/world_flags/world_flags.json`
- Author the first hidden hooks as passive objectives in Q005–Q008 (per STORY_DESIGN_CONTEXT.md: every 3–5 quests)
- Add phase-pressure content files for phases 1–3 (phases 4–6 are content-authored later as story expands)
- Author Kowalski as a pressure sender in the phase 2 and 3 profiles

**Files changed:** All 8 quest JSONs, `content/tickets/T007.json`, `content/docs/onboarding.json`, `server/src/services/ShiftReviewService.js`, `server/src/services/EmailService.js`, `content/world_flags/world_flags.json`, `content/pressure_profiles/` (new files)  
**Risk:** Medium — touching all quest files; run `validate-content.js` after every file change

---

### Task 11 — Validation and tests

**What changes:**
- Update `validate-content.js`:
  - Error on unrecognized `narrative_phase` value
  - Warn on missing `narrative_phase`
  - Validate `behavior_impact` structure (numeric deltas)
  - Validate `hidden_hook` structure if present
  - Warn if `linux_concepts` is empty
  - Check `access_requirements.minimum_access` values against known VM IDs
- Add unit tests:
  - `BehaviorTracker.test.js` — apply deltas, persistence, initialize from state
  - `NarrativePhaseTracker.test.js` — advance rules, phase ordering, initialize
  - `EndingEvaluator.test.js` — all 4 endings, boundary conditions
  - `HiddenHookTracker.test.js` — discover, isDiscovered, persistence
- Extend existing tests:
  - `ValidationEngine.test.js` — confirm hidden objectives with `hidden: true` don't affect normal branch resolution
  - `TicketService.test.js` — confirm `behavior_impact` is applied at completion, confirm no-op when field absent
- Manual test checklist (see Task 11 Codex prompt)

**Files changed:** `tools/content/validate-content.js`, `server/src/services/BehaviorTracker.test.js` (new), `server/src/services/NarrativePhaseTracker.test.js` (new), `server/src/services/EndingEvaluator.test.js` (new), `server/src/services/HiddenHookTracker.test.js` (new)  
**Risk:** Low — tests are additive

---

## 5. Files Likely to Change

| File | Why | What changes | Risk |
|---|---|---|---|
| `server/src/services/SaveState.js` | New save keys needed | Add `behavior`, `narrative_phase`, `suspicion`, `hidden_hooks_discovered` to `_defaultState()`; bump `schema_version` to 3 | Low — `_applyDefaults` merges safely |
| `server/src/services/QuestEngine.js` | Phase advancement hook | Call `narrativePhaseTracker.advance()` in `complete()`; import new service | Low |
| `server/src/services/TicketService.js` | Behavior application | Call `behaviorTracker.apply()` after branch selection in `markComplete()` | Low — branch.behavior_impact is optional |
| `server/src/services/ShiftReviewService.js` | Name correction | Change `'Priya Kapoor'` to `'Priya Nair'`; fix `p.kapoor` to `p.nair` in email From line | Low — one-liner |
| `server/src/services/EmailService.js` | Name correction | Change `CHARACTER_EMAILS.priya` to `'Priya Nair <p.nair@axiomworks.internal>'` | Low — one-liner |
| `server/src/services/IncidentScheduler.js` | Phase pressure | Add `_processPhasePresure()` method triggered by phase change event | Medium |
| `server/src/services/ProgressionSystem.js` | Access level label | Add `getAccessLevel()` that derives `basic_user | sudo | root` from current `unlocked_access` set | Low |
| `server/src/routes/state.js` | Expose new state | Add `behavior`, `narrativePhase`, `accessLevel`, `suspicion` to GET /api/state response | Low |
| `server/src/index.js` | Wire new services | Import and `initialize()` new services in the correct order; add debug router | Low |
| `tools/content/validate-content.js` | Validate new schema fields | Add phase enum check, behavior_impact structure check, hidden_hook shape check | Low — additive |
| `content/world_flags/world_flags.json` | New flags needed | Add entries for any new flags emitted by hidden hooks and phase pressure profiles | Low |
| `content/tickets/T007.json` | Priya name | Update `from` field if it uses old email | Low |
| `content/docs/onboarding.json` | Priya name | Update any references to Priya Kapoor or Priya Singh | Low |
| All 8 quest JSONs | New fields | Add `narrative_phase`, `behavior_impact`, `hidden_hook`, `linux_concepts`, `failure_conditions`, `access_requirements` | Medium — large surface |

---

## 6. Files Likely to Be Added

| File | Purpose | Expected structure |
|---|---|---|
| `server/src/services/BehaviorTracker.js` | Track curiosity/obedience/risk/suspicion | Class with `initialize()`, `apply(impact)`, `getSnapshot()`, `_persist()` |
| `server/src/services/NarrativePhaseTracker.js` | Track and advance narrative phase | Class with `initialize()`, `advance(questId)`, `getPhase()`, `_persist()` |
| `server/src/services/HiddenHookTracker.js` | Record hidden hook discoveries | Class with `initialize()`, `discover(id)`, `isDiscovered(id)`, `getDiscovered()` |
| `server/src/services/EndingEvaluator.js` | Evaluate ending trajectory from world state | Class with `evaluate()`, `checkTrigger()`, pure computation over save state snapshot |
| `server/src/routes/debug.js` | Dev-only debug API | Express router, gated on `NODE_ENV !== 'production'` |
| `frontend/src/components/DebugPanel.svelte` | Dev-only debug overlay | Collapsible panel, shown on `?debug=1`, polling `/api/debug/state` |
| `content/progression/access_levels.json` | Named access level threshold definitions | Array of `{ level, trust_threshold, suspicion_ceiling, grants, revokes }` |
| `content/pressure_profiles/kowalski_phase_1.json` | Phase 1 boss pressure | `escalation_steps` with Kowalski emails at time thresholds |
| `content/pressure_profiles/kowalski_phase_2.json` | Phase 2 boss pressure | Dismissive Kowalski CC patterns |
| `content/pressure_profiles/kowalski_phase_3.json` | Phase 3 boss pressure | Suspicious Kowalski, Priya CC |
| `server/src/services/BehaviorTracker.test.js` | Unit tests for BehaviorTracker | Jest test file using existing `IncidentScheduler.test.js` as pattern |
| `server/src/services/NarrativePhaseTracker.test.js` | Unit tests for NarrativePhaseTracker | Jest test file |
| `server/src/services/EndingEvaluator.test.js` | Unit tests for EndingEvaluator | Jest test file, covers all 4 endings |
| `server/src/services/HiddenHookTracker.test.js` | Unit tests for HiddenHookTracker | Jest test file |

---

## 7. Data Migration Plan

### Existing quests (Q001–Q008)

**Strategy: Wrap into new schema (backward-compatible extension)**

- Do NOT replace existing quests. Do NOT create a "legacy" tier.
- Add new fields to each existing quest file. The fields are additive.
- `ContentLoader.js` already loads all quest files and passes them to `QuestEngine`. New fields are simply available at resolution time.
- Missing new fields in old quests: the runtime treats `narrative_phase: undefined` as `normal_work`; `behavior_impact: undefined` as no behavior change; `hidden_hook: null` as no hook.
- This means existing quests continue to work with zero runtime errors before Task 10 runs.

### Save state migration

- `schema_version` bumps from `2` to `3`
- `SaveState._applyDefaults()` already merges new keys safely: old saves that lack `behavior`, `narrative_phase`, `suspicion`, `hidden_hooks_discovered` will receive the default values (`50/50/50`, `'normal_work'`, `0`, `[]`) on next load
- No destructive migration. No migration script needed.
- Old saves loaded under the new schema will behave as if the player is in Phase 1 with neutral behavior — which is correct for a save that predates the new system.

### Tickets, dialogue, incidents

- No migration needed. Existing files continue to load and function.
- New dialogue files for phase pressure and boss escalation are additive.

---

## 8. Testing Plan

### Unit tests (new)

| Test file | What it covers |
|---|---|
| `BehaviorTracker.test.js` | Delta application, clamping (0–100), initialize from state, persist, event emission |
| `NarrativePhaseTracker.test.js` | Phase ordering (spine), advance-only-forward rule, initialize from state, persist |
| `EndingEvaluator.test.js` | All 4 endings by state construction, boundary conditions, tie-break rules |
| `HiddenHookTracker.test.js` | Discover, isDiscovered, idempotent discover, initialize from state |

### Integration tests (extend existing)

| Test | Assertion |
|---|---|
| `TicketService.test.js` — behavior applied | After `markComplete`, save state `behavior.curiosity` changes by branch delta |
| `TicketService.test.js` — behavior absent | Quest with no `behavior_impact` completes without error |
| `ValidationEngine.test.js` — hidden objective | `hidden: true` objective validates passively without blocking branch resolution |
| `IncidentScheduler.test.js` — phase pressure | Phase change event triggers correct pressure profile activation |

### Save/load compatibility checks

- Load an existing (schema_version 2) save: all new keys initialized to defaults, no error
- Complete a new quest with new schema fields: save state includes correct behavior deltas
- Restart server with schema_version 3 save: all new keys correctly restored
- Test `SAVE_DIR` override with new schema

### Manual test checklist

1. Complete Q001 clean fix → confirm `player_ssh_configured` flag set, trust = 53
2. Complete Q001 brittle fix → confirm trust penalty, `player_loose_permissions` flag set
3. After any quest completion → confirm `behavior` object in `/api/state` (via debug route) has changed
4. With `?debug=1` → confirm debug panel visible in frontend
5. Complete Q001–Q003 → confirm narrative phase advances from `normal_work`
6. Navigate terminal to a hidden anomaly (e.g., unknown user in `/etc/passwd`) → confirm `/api/debug/hidden-hooks` shows new entry
7. Force phase 3 via debug route → confirm Kowalski pressure profile activates
8. Force behavior state to `{ curiosity: 80, obedience: 20, risk: 30 }` + reach resolution phase → confirm EndingEvaluator returns `exposure`
9. Force behavior state to `{ curiosity: 20, obedience: 80, risk: 20 }` + reach resolution phase → confirm `corporate_loop`
10. Run `node tools/content/validate-content.js` — zero errors with all existing + updated quests
11. Run `npm test` — all existing tests pass; all new unit tests pass

### Content validation checks

- After Task 10: run `validate-content.js --verbose` on all 8 updated quests
- Confirm all new `narrative_phase` values are valid enum members
- Confirm all new `behavior_impact` fields have numeric deltas
- Confirm no undeclared world flags introduced
- Confirm all `hidden_hook` IDs are unique across quests

---

## 9. Codex Delegation Prompts

### Task 2 — Extend validate-content.js

```
File: tools/content/validate-content.js

Extend the existing content validation tool. Do not change any existing checks. Add these new checks after the existing quest validation block:

1. Define a constant at the top of the file:
   const VALID_NARRATIVE_PHASES = new Set(["normal_work","unease","suspicion","investigation","conflict","resolution"]);

2. In the quest validation loop (the `for (const [qid, { data: quest, fname }] of Object.entries(quests))` block), add after the existing checks:

   // narrative_phase
   if (!quest.narrative_phase) {
     warn(`${ctx}: missing 'narrative_phase' field`);
   } else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) {
     err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`);
   }

   // behavior_impact
   if (quest.behavior_impact !== undefined) {
     for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) {
       for (const field of ['curiosity_delta','obedience_delta','risk_delta','suspicion_delta']) {
         if (impact[field] !== undefined && typeof impact[field] !== 'number') {
           err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`);
         }
       }
     }
   }

   // hidden_hook shape (if present and not null)
   if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) {
     if (typeof quest.hidden_hook.id !== 'string') {
       err(`${ctx}: hidden_hook.id must be a string`);
     }
   }

   // access_requirements
   if (quest.access_requirements?.minimum_access) {
     for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) {
       if (!vmProfiles[vmId]) {
         err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`);
       }
     }
   }

Acceptance criteria:
- `node tools/content/validate-content.js` runs without JS errors
- Existing quest files produce only warnings for missing narrative_phase, not errors
- A test quest with narrative_phase: "invalid_phase" produces one error
- All other existing checks continue to pass
```

---

### Task 3 — BehaviorTracker service

```
Create file: server/src/services/BehaviorTracker.js

Use ES module syntax (import/export) matching the existing service style (see SaveState.js and TrustSystem.js as patterns).

The class must:
- Store { curiosity, obedience, risk, suspicion } — all numeric 0–100, starting at 50/50/50/0
- initialize(state): load from state.behavior (use defaults if absent)
- apply(impact): accept an object with optional fields { curiosity_delta, obedience_delta, risk_delta, suspicion_delta }, add each to the corresponding score, clamp to [0,100], persist, emit 'behavior:changed' via eventBus
- getSnapshot(): return a plain { curiosity, obedience, risk, suspicion } object
- _persist(): call saveState.set({ behavior: this.getSnapshot() })

Export a singleton: export const behaviorTracker = new BehaviorTracker();

Then make these changes:

1. In server/src/services/SaveState.js, in _defaultState(), add this key alongside the existing ones:
   behavior: { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 },
   and change schema_version from 2 to 3.

2. In server/src/index.js, import behaviorTracker from './services/BehaviorTracker.js' and add behaviorTracker.initialize(state) in initializeServices() after trustSystem.initialize(state).

3. In server/src/services/TicketService.js, in the markComplete() method, after the line `questEngine.complete(quest.id, { branchId: branch.id });`, add:
   const behaviorImpact = branch.behavior_impact ?? quest.behavior_impact?.default ?? quest.behavior_impact ?? null;
   if (behaviorImpact) { behaviorTracker.apply(behaviorImpact); }
   (Add the import at the top of the file.)

Acceptance criteria:
- npm test passes (existing tests unchanged)
- GET /api/debug/state (if debug route exists) shows behavior object
- After completing a quest whose branch has behavior_impact.curiosity_delta: 2, the save.json shows behavior.curiosity incremented by 2
```

---

### Task 4 — NarrativePhaseTracker service

```
Create file: server/src/services/NarrativePhaseTracker.js

Use ES module syntax matching existing service patterns.

Phase ordering (spine): normal_work < unease < suspicion < investigation < conflict < resolution

The class must:
- Store _phase as a string, initialized from state.narrative_phase or defaulting to 'normal_work'
- PHASE_ORDER constant: ['normal_work','unease','suspicion','investigation','conflict','resolution']
- initialize(state): restore _phase from state.narrative_phase
- advance(questId): look up the quest from contentLoader, read its narrative_phase field; if the quest's phase rank is strictly higher than current phase rank, update _phase, persist, emit 'narrative:phase_changed' event with { from, to }; if narrative_phase field is absent or undefined, do nothing
- getPhase(): return current _phase string
- _persist(): saveState.set({ narrative_phase: this._phase })

Export singleton: export const narrativePhaseTracker = new NarrativePhaseTracker();

Then make these changes:

1. In server/src/services/SaveState.js _defaultState(), add:
   narrative_phase: 'normal_work',

2. In server/src/services/QuestEngine.js complete() method, after this._persist(), add:
   narrativePhaseTracker.advance(questId);
   (Add the import at top of file.)

3. In server/src/routes/state.js, add narrativePhase: narrativePhaseTracker.getPhase() to the GET / response object.
   Import narrativePhaseTracker at top of the file.

4. In server/src/index.js, import and initialize narrativePhaseTracker in initializeServices() after questEngine.initialize(state).

Acceptance criteria:
- npm test passes
- After completing Q001, GET /api/state returns narrativePhase: 'normal_work'
- If a quest with narrative_phase: 'unease' is completed after Q001, GET /api/state returns narrativePhase: 'unease'
- Phase never goes backward: completing a 'normal_work' quest after an 'unease' quest does not revert the phase
```

---

### Task 5 — HiddenHookTracker service

```
Create file: server/src/services/HiddenHookTracker.js

ES module syntax, matching existing service patterns.

The class must:
- Store _discovered as a Set of hook ID strings
- initialize(state): load from state.hidden_hooks_discovered (array), build Set
- discover(hookId): if not already discovered, add to Set, persist, emit 'hidden_hook:discovered' with { hookId }; idempotent if already discovered
- isDiscovered(hookId): boolean
- getDiscovered(): return [...this._discovered] sorted
- _persist(): saveState.set({ hidden_hooks_discovered: [...this._discovered] })

Export singleton: export const hiddenHookTracker = new HiddenHookTracker();

Then:

1. In server/src/services/SaveState.js _defaultState(), add:
   hidden_hooks_discovered: [],

2. In server/src/index.js, import and call hiddenHookTracker.initialize(state) in initializeServices().

3. In server/src/routes/state.js, add hiddenHooksDiscovered: hiddenHookTracker.getDiscovered() to the response.

Acceptance criteria:
- npm test passes
- POST /api/debug/discover-hook/test-hook (if debug route exists) adds 'test-hook' to state
- GET /api/state returns hiddenHooksDiscovered: ['test-hook']
- Calling discover() twice with the same ID results in exactly one entry in the array
```

---

### Task 6 — Access level extension

```
Make these targeted changes to existing files:

1. In server/src/services/ProgressionSystem.js, add this method to the ProgressionSystem class:
   getAccessLevel() {
     if (this._access.has('sudo:workstation:full') || this._access.has('sudo:web_server:full') || this._access.has('sudo:build_machine:full')) {
       return 'root';
     }
     if (this._access.has('sudo:workstation:systemctl') || this._access.has('ssh:web_server') || this._access.has('ssh:build_machine')) {
       return 'sudo';
     }
     return 'basic_user';
   }

2. In server/src/routes/state.js, add to the GET / response:
   accessLevel: progressionSystem.getAccessLevel(),
   Import progressionSystem if not already imported.

3. Create file: content/progression/access_levels.json with this content:
   {
     "_description": "Named access level definitions. Derived from ProgressionSystem unlocked_access keys.",
     "levels": [
       { "name": "basic_user", "description": "Default access. Workstation only. No sudo." },
       { "name": "sudo", "description": "Sudo on workstation; SSH to hermes or vulcan." },
       { "name": "root", "description": "Full sudo on at least one remote host." }
     ]
   }

Acceptance criteria:
- npm test passes
- GET /api/state returns accessLevel: 'basic_user' for a fresh save
- After trust reaches 55, accessLevel returns 'sudo'
- After trust reaches 60 and sudo:web_server:full is granted, accessLevel returns 'root'
```

---

### Task 7 — Phase pressure content files

```
Create three new pressure profile files in content/pressure_profiles/:

File: content/pressure_profiles/kowalski_phase_1.json
Content:
{
  "id": "kowalski_phase_1",
  "label": "Dave Kowalski — Phase 1: Routine Pressure",
  "description": "Normal managerial check-ins. Annoying but not threatening.",
  "trigger_phase": "normal_work",
  "escalation_steps": [
    {
      "trigger_after_seconds": 300,
      "notification": "Quick check-in — how are you getting on with the ticket queue? Let me know if anything is blocking you. Dave K.",
      "notification_severity": "info",
      "sender": "Dave Kowalski <d.kowalski@axiomworks.internal>",
      "subject": "Status check"
    },
    {
      "trigger_after_seconds": 600,
      "notification": "Following up on my earlier note. We should really document that workflow once you get a moment.",
      "notification_severity": "info",
      "sender": "Dave Kowalski <d.kowalski@axiomworks.internal>",
      "subject": "Re: Status check"
    }
  ]
}

File: content/pressure_profiles/kowalski_phase_2.json
Content:
{
  "id": "kowalski_phase_2",
  "label": "Dave Kowalski — Phase 2: Dismissive",
  "description": "Kowalski is aware something is recurring. Manages upward, not inward.",
  "trigger_phase": "unease",
  "escalation_steps": [
    {
      "trigger_after_seconds": 180,
      "notification": "I've had a couple of questions from Sarah's team about stability. Nothing critical, but let's make sure we're on top of it. Noted for the weekly update. D.",
      "notification_severity": "info",
      "sender": "Dave Kowalski <d.kowalski@axiomworks.internal>",
      "subject": "FYI — product team questions"
    }
  ]
}

File: content/pressure_profiles/kowalski_phase_3.json
Content:
{
  "id": "kowalski_phase_3",
  "label": "Dave Kowalski — Phase 3: Suspicious",
  "description": "Kowalski is getting questions from above. Starts involving Priya.",
  "trigger_phase": "suspicion",
  "escalation_steps": [
    {
      "trigger_after_seconds": 120,
      "notification": "I've scheduled a brief sync for Thursday to talk through recent changes on the infrastructure side. Priya will join. Nothing to worry about — just a routine review.",
      "notification_severity": "warning",
      "sender": "Dave Kowalski <d.kowalski@axiomworks.internal>",
      "subject": "Thursday sync — infra review"
    }
  ]
}

Acceptance criteria:
- node tools/content/validate-content.js passes with no new errors
- All three files have unique 'id' fields that pass content loader's ID detection
```

---

### Task 8 — EndingEvaluator service

```
Create file: server/src/services/EndingEvaluator.js

ES module syntax.

ENDING_CRITERIA constant (all conditions must be met for that ending to be active):
- exposure:      curiosity >= 65, hidden_hooks_discovered.length >= 2, narrative_phase rank >= 'investigation'
- corporate_loop: obedience >= 65, curiosity <= 40, trust >= 65
- burnout:        curiosity <= 35, obedience <= 40 (passive disengagement)
- chaos:          risk >= 65, trust <= 40

The class must:
- evaluate(): read current saveState, compute which endings' criteria are met, return { active: 'exposure'|'corporate_loop'|'burnout'|'chaos'|'undetermined', candidates: [...] } — if multiple match, prefer in this order: exposure > chaos > corporate_loop > burnout
- checkTrigger(): call evaluate(); if narrative_phase is 'resolution' and active is not 'undetermined', emit 'ending:triggered' with { ending: active }; return the result

PHASE_RANK constant: { normal_work:0, unease:1, suspicion:2, investigation:3, conflict:4, resolution:5 }

Import saveState, narrativePhaseTracker, hiddenHookTracker, behaviorTracker.

Export singleton: export const endingEvaluator = new EndingEvaluator();

Wire into index.js: import endingEvaluator; add endingEvaluator (no initialize needed, it reads state on demand).

Listen for 'quest:completed' on eventBus: call endingEvaluator.checkTrigger() each time.

Acceptance criteria:
- npm test passes
- evaluate() with curiosity=70, hiddenHooksDiscovered=['h1','h2'], phase='investigation' returns active: 'exposure'
- evaluate() with obedience=70, curiosity=35, trust=70 returns active: 'corporate_loop'
- evaluate() with no conditions met returns active: 'undetermined'
```

---

### Task 9 — Debug routes and frontend panel

```
Create file: server/src/routes/debug.js

ES module syntax. Only register routes if process.env.NODE_ENV !== 'production'.

Routes:
  GET /api/debug/state       — return full saveState.get()
  GET /api/debug/behavior    — return behaviorTracker.getSnapshot()
  GET /api/debug/phase       — return { phase: narrativePhaseTracker.getPhase() }
  GET /api/debug/ending      — return endingEvaluator.evaluate()
  GET /api/debug/hidden-hooks — return { discovered: hiddenHookTracker.getDiscovered(), total: N }
  POST /api/debug/set-behavior — body: { curiosity, obedience, risk, suspicion }; call behaviorTracker._override(body) (add _override method that directly sets values without deltas)
  POST /api/debug/set-phase  — body: { phase }; if valid phase, directly set _phase on narrativePhaseTracker and persist (add _forcePhase method)
  POST /api/debug/discover-hook/:id — call hiddenHookTracker.discover(req.params.id); return getDiscovered()

In server/src/index.js, add:
  import debugRouter from './routes/debug.js';
  // After the other app.use() calls:
  if (process.env.NODE_ENV !== 'production') {
    app.use('/api/debug', debugRouter);
  }

Create file: frontend/src/components/DebugPanel.svelte
- Shows only when window.location.search includes 'debug=1'
- Polls GET /api/debug/behavior, GET /api/debug/phase, GET /api/debug/ending every 5 seconds
- Displays: behavior scores (curiosity/obedience/risk/suspicion), current phase, ending trajectory
- Minimal styling: position fixed, bottom right, semi-transparent, small font

In frontend/src/App.svelte, import DebugPanel and conditionally render it:
  {#if showDebug}
    <DebugPanel />
  {/if}
Add: const showDebug = new URLSearchParams(window.location.search).has('debug');

Acceptance criteria:
- npm test passes
- In development: GET /api/debug/behavior returns behavior snapshot
- Visiting /?debug=1 shows the debug panel in the browser
- In production (NODE_ENV=production): GET /api/debug/behavior returns 404
```

---

### Task 10 — Fix Priya's name and update Q001–Q008

```
Part A — Fix Priya's name. Make these exact changes:

1. In server/src/services/EmailService.js, find this line:
     priya: 'Priya Kapoor <p.kapoor@axiomworks.internal>',
   Change it to:
     priya: 'Priya Nair <p.nair@axiomworks.internal>',

2. In server/src/services/ShiftReviewService.js:
   a. Find: reviewer: 'Priya Kapoor'
      Change to: reviewer: 'Priya Nair'
   b. Find: from: 'Priya Kapoor <p.kapoor@axiomworks.internal>'
      Change to: from: 'Priya Nair <p.nair@axiomworks.internal>'

3. In content/tickets/T007.json: if the 'from' or 'body' field contains 'Priya Kapoor', 'p.kapoor', or 'Priya Singh', replace with 'Priya Nair' and 'p.nair@axiomworks.internal'.

4. In content/docs/onboarding.json: if 'Priya Kapoor' or 'Priya Singh' appears, replace with 'Priya Nair'.

Part B — Add new fields to existing quests. For each quest Q001–Q008, add these fields using the values in the table below. Do not change any existing fields. Do not reformat the JSON beyond what is needed to add the new fields.

Q001: narrative_phase: "normal_work", linux_concepts: ["ssh-keygen","authorized_keys","file permissions"], failure_conditions: ["SSH keys not added","authorized_keys permissions too broad"], behavior_impact: { "correct-key": { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 }, "loose-permissions": { curiosity_delta: 0, obedience_delta: 0, risk_delta: 1, suspicion_delta: 1 }, default: { curiosity_delta: 0, obedience_delta: 0, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: null, access_requirements: { minimum_access: { workstation: "basic_user" }, requires_root: false, temporary_grants_allowed: [] }

Q002: narrative_phase: "normal_work", linux_concepts: ["nginx","systemctl","sshd_config"], failure_conditions: ["nginx not running","service not enabled at boot"], behavior_impact: { default: { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: null, access_requirements: { minimum_access: { web_server: "basic_user" }, requires_root: false, temporary_grants_allowed: [] }

Q003: narrative_phase: "normal_work", linux_concepts: ["logrotate","disk usage","df","du"], failure_conditions: ["disk still above threshold","logrotate not restored"], behavior_impact: { default: { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: null, access_requirements: { minimum_access: { web_server: "sudo" }, requires_root: false, temporary_grants_allowed: [] }

Q004: narrative_phase: "normal_work", linux_concepts: ["chown","file ownership","deploy scripts"], failure_conditions: ["web root ownership not fixed","deploy service still failing"], behavior_impact: { default: { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: null, access_requirements: { minimum_access: { web_server: "sudo" }, requires_root: false, temporary_grants_allowed: [] }

Q005: narrative_phase: "unease", linux_concepts: ["cron","crontab","user field","backup management"], failure_conditions: ["cron still running as root","disk not cleared","backup directory ownership not fixed"], behavior_impact: { "full-fix": { curiosity_delta: 1, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 }, "cron-fixed-only": { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 }, "disk-cleared-only": { curiosity_delta: 0, obedience_delta: 0, risk_delta: 1, suspicion_delta: 1 }, default: { curiosity_delta: 0, obedience_delta: 0, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: { "id": "q005_backup_agent_history", "description": "backup-agent home directory contains a .bash_history with unusual commands that predate the current cron misconfiguration.", "discovery_method": "Player reads /home/backup-agent/.bash_history", "significance": "Dale configured this cron job. The history shows it was changed deliberately, not by accident." }, access_requirements: { minimum_access: { web_server: "sudo" }, requires_root: false, temporary_grants_allowed: [] }

Q006: narrative_phase: "unease", linux_concepts: ["NTP","systemd-timesyncd","Arch Linux","pacman","package keys"], failure_conditions: ["NTP not enabled at boot","package manager still broken"], behavior_impact: { default: { curiosity_delta: 0, obedience_delta: 1, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: null, access_requirements: { minimum_access: { build_machine: "sudo" }, requires_root: false, temporary_grants_allowed: [] }

Q007: narrative_phase: "suspicion", linux_concepts: ["sshd_config","AllowGroups","AllowUsers","access hardening"], failure_conditions: ["Priya still locked out","SSH restrictions removed entirely"], behavior_impact: { default: { curiosity_delta: 1, obedience_delta: 0, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: { "id": "q007_dale_ssh_key", "description": "An SSH key in hermes /root/.ssh/authorized_keys does not match any current staff. The fingerprint matches no documented key.", "discovery_method": "Player reads /root/.ssh/authorized_keys on hermes", "significance": "Dale had root SSH access to hermes that was never formally revoked." }, access_requirements: { minimum_access: { web_server: "sudo" }, requires_root: false, temporary_grants_allowed: ["sudo:web_server:sshd"] }

Q008: narrative_phase: "suspicion", linux_concepts: ["apt","package pinning","apt-preferences","internal package mirror","vulcan build pipeline"], failure_conditions: ["axiomworks-app still broken","bad package not traced to build machine"], behavior_impact: { default: { curiosity_delta: 1, obedience_delta: 0, risk_delta: 0, suspicion_delta: 0 } }, hidden_hook: { "id": "q008_build_log_anomaly", "description": "vulcan's build log for 2.1.1 shows it was triggered by a manual invocation, not the automated pipeline, at 02:14.", "discovery_method": "Player reads /var/log/build-pipeline.log on vulcan and notices the timestamp and manual trigger field", "significance": "The bad build was triggered manually. Someone made the broken build, and it was not the pipeline." }, access_requirements: { minimum_access: { build_machine: "sudo", web_server: "sudo" }, requires_root: false, temporary_grants_allowed: [] }

After all changes, run: node tools/content/validate-content.js
Confirm: zero errors. Warnings about missing narrative_phase should now be gone for all 8 quests.
```

---

### Task 11 — Unit tests and validation extension

```
Part A — Write unit tests for all new services.

Create file: server/src/services/BehaviorTracker.test.js
Use the existing IncidentScheduler.test.js or ShiftReviewService.test.js as the pattern for test structure.

Tests to include:
1. initialize() with no state.behavior: curiosity=50, obedience=50, risk=50, suspicion=0
2. initialize() with existing state.behavior: values restored correctly
3. apply({ curiosity_delta: 5 }): curiosity increases by 5
4. apply({ risk_delta: -10 }): risk decreases by 10, floor at 0
5. apply({ suspicion_delta: 200 }): suspicion clamps at 100
6. apply({}): no change, no error
7. apply(null): no change, no error (defensive)
8. getSnapshot(): returns plain object with all four keys

Create file: server/src/services/NarrativePhaseTracker.test.js
Tests:
1. initialize() with no state.narrative_phase: returns 'normal_work'
2. advance() with quest having narrative_phase 'unease': phase becomes 'unease'
3. advance() with quest having higher phase than current: phase advances
4. advance() with quest having lower phase than current: phase does NOT change
5. advance() with quest missing narrative_phase field: phase does NOT change
6. getPhase(): returns current phase string

Create file: server/src/services/EndingEvaluator.test.js
Tests (each builds a mock state):
1. exposure: curiosity=70, hiddenHooksDiscovered=['a','b'], phase='investigation' → active: 'exposure'
2. corporate_loop: obedience=70, curiosity=35, trust=70 → active: 'corporate_loop'
3. burnout: curiosity=30, obedience=35 → active: 'burnout'
4. chaos: risk=70, trust=35 → active: 'chaos'
5. no conditions: active: 'undetermined'
6. exposure wins over chaos when both met: active: 'exposure'

Create file: server/src/services/HiddenHookTracker.test.js
Tests:
1. initialize() with no state: getDiscovered() returns []
2. discover('h1'): getDiscovered() returns ['h1']
3. discover('h1') twice: getDiscovered() returns ['h1'] (idempotent)
4. isDiscovered('h1'): true after discovery
5. isDiscovered('h2'): false before discovery

Part B — Run validation.
After all changes: run `npm test` from the server directory. All tests must pass.
Run `node tools/content/validate-content.js`. Zero errors.

Part C — Manual verification checklist.
Confirm each item by inspection or running the game:
[ ] Fresh save: GET /api/state returns behavior: {curiosity:50,obedience:50,risk:50,suspicion:0}, narrativePhase:'normal_work', accessLevel:'basic_user'
[ ] Complete Q001 clean branch: behavior.obedience increments, phase stays normal_work
[ ] Complete Q005: phase advances to 'unease', hidden_hook for q005_backup_agent_history visible in /api/debug/hidden-hooks
[ ] Complete Q007: phase advances to 'suspicion', q007_dale_ssh_key hook discoverable on hermes
[ ] ShiftReviewService sends from Priya Nair <p.nair@axiomworks.internal>
[ ] GET /api/debug/ending with forced state returns correct ending label
[ ] /?debug=1 shows debug panel in browser
[ ] node tools/content/validate-content.js: zero errors
```

---

*End of implementation plan.*
