Files
sysadmin-chronicles/docs/design/sysadmin_chronicles_repo_implementation_plan.md
T
44r0n7 0265afa054 chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
2026-05-02 11:49:07 -04:00

55 KiB
Raw Blame History

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 (Q001Q008)
  • 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 (0100, 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 Q005Q008 (per STORY_DESIGN_CONTEXT.md: every 35 quests)
  • Add phase-pressure content files for phases 13 (phases 46 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
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 (Q001Q008)

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 (0100), 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 Q001Q003 → 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 0100, 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 Q001Q008

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 Q001Q008, 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.