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.
55 KiB
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_requirementsagainst currentworld_flagsin 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:- Runs
ValidationEngine.resolveBranch(quest)to find winning branch - Applies
branch.world_flagsto save state - Calls
trustSystem.adjust(branch.trust_delta) - Calls
questEngine.complete() - Sends follow-up dialogue email if trust delta ≤ 0
- Activates follow-up ticket via
_activateFollowUpTicket() - Emits
ticket:completedevent
- Runs
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 vialinked_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
- Server loads via
contentLoader.load()then initializes services fromsaveState.get() QuestEngine.initialize()restores quest state from save; auto-activates quests with no requirementsTicketService.initialize()cross-references quest state to activate/resolve ticket entries- Player submits a
POST /api/tickets/:id/completerequest TicketService.markComplete()runs full validation → branch resolution → state mutation → events- 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.jshardcodesreviewer: 'Priya Kapoor'and sends fromp.kapoor@axiomworks.internal. This must be corrected to Priya Nair /p.nair@axiomworks.internalbefore shipping any new content.EmailService.jsCHARACTER_EMAILS haspriya: 'Priya Kapoor <p.kapoor@axiomworks.internal>'. Same fix required.content/tickets/T007.jsonmay still reference the old Priya name (noted in CHARACTERS.md).content/docs/onboarding.jsonmay 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_requirementsas optional fields to the quest JSON schema - Update
validate-content.jsto: warn whennarrative_phaseis absent, validatenarrative_phaseagainst the 6-value enum, checkbehavior_impactstructure if present, validatehidden_hookshape if present, checkaccess_requirements.minimum_accessagainst 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,riskas 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:changedevent on change
- Tracks
- Add
behaviorkey toSaveState._defaultState()with schema_version bump to 3 SaveState._applyDefaults()already merges new keys safely — no migration needed for existing saves- Wire
behaviorTracker.initialize(state)intoserver/src/index.jsinitializeServices() - Call
behaviorTracker.apply(branch.behavior_impact?.[branch.id] ?? branch.behavior_impact?.default ?? {})insideTicketService.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'snarrative_phasefield and updates phase if it is higher on the spine - Method:
initialize(state)— restores fromstate.narrative_phase - Persists via
saveState.set({ narrative_phase: ... }) - Emits
narrative:phase_changedevent
- Maintains current phase as one of:
- Add
narrative_phasekey toSaveState._defaultState()with value'normal_work' - Call
narrativePhaseTracker.advance(questId)insideQuestEngine.complete()after state mutation - Expose
narrativePhasein/api/stateresponse (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()addshidden_hooks_discovered: []- New service:
server/src/services/HiddenHookTracker.js- Method:
discover(hookId)— adds hookId to discovered list, persists, emitshidden_hook:discovered - Method:
isDiscovered(hookId)— boolean check - Method:
getDiscovered()— returns array - Method:
initialize(state)— restores from save
- Method:
- New API route (dev/admin only):
GET /api/debug/hidden-hooks— returns discovered hooks and all declared hooks from quest JSON HiddenHookdiscovery 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 withcheck_mode: "passive"andbehavior_impactofcuriosity: +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
ProgressionSystemwith a named three-tier concept:basic_user— default, always availablesudo— granted by trust threshold (already exists asunlocked_accessstrings, just unnamed)root— granted at higher trust threshold
- Add
content/progression/access_levels.json— defines access level thresholds (trust + suspicion gates) - Add
suspicionkey toSaveState._defaultState()with value0 - Add
suspiciontracking toBehaviorTracker(or a thinSuspicionTracker) — updated wheneverriskbehavior delta fires - Suspicion threshold: if
suspicion >= 70, revoke certain access levels (mirror of trust revoke logic) - Add
access_levelcomputed field to/api/stateresponse:basic_user | sudo | rootbased on currentunlocked_accessset trust_unlocks.jsonentries can remain as-is; theaccess_levellabel 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
IncidentSchedulerto also process aphase_pressuretracker:- When
narrativePhaseTracker.getPhase()changes, activate the matching phase pressure profile - Phase pressure escalation steps are sent as
emailService.send()from Kowalski or Priya
- When
- Add
follow_up_mailfield support to incident escalation steps (already possible viaemailService.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 reachedinvestigationorconflict, hidden hooks discovered ≥ Ncorporate_loop: high obedience, low curiosity, trust > 70, few hidden hooks discoveredburnout: low obedience AND low curiosity, trust medium-low, many unresolved incidentschaos: 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, firesending:triggeredevent
- New API endpoint:
GET /api/debug/ending— returns current ending trajectory (dev only) - The ending trigger should NOT be a single button.
EndingEvaluatoris called passively onquest:completedevents.
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 whenNODE_ENV !== 'production'GET /api/debug/state— full save state dumpGET /api/debug/behavior— current behavior snapshot (curiosity/obedience/risk/suspicion)GET /api/debug/phase— current narrative phaseGET /api/debug/ending— current ending trajectoryGET /api/debug/hidden-hooks— discovered + undiscovered hooksPOST /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.jsbehindNODE_ENVguard - Add a minimal debug panel to the frontend (dev only): collapsible overlay showing behavior, phase, ending trajectory — controlled by
?debug=1query 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_phasevalue - Warn on missing
narrative_phase - Validate
behavior_impactstructure (numeric deltas) - Validate
hidden_hookstructure if present - Warn if
linux_conceptsis empty - Check
access_requirements.minimum_accessvalues against known VM IDs
- Error on unrecognized
- Add unit tests:
BehaviorTracker.test.js— apply deltas, persistence, initialize from stateNarrativePhaseTracker.test.js— advance rules, phase ordering, initializeEndingEvaluator.test.js— all 4 endings, boundary conditionsHiddenHookTracker.test.js— discover, isDiscovered, persistence
- Extend existing tests:
ValidationEngine.test.js— confirm hidden objectives withhidden: truedon't affect normal branch resolutionTicketService.test.js— confirmbehavior_impactis 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 (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.jsalready loads all quest files and passes them toQuestEngine. New fields are simply available at resolution time.- Missing new fields in old quests: the runtime treats
narrative_phase: undefinedasnormal_work;behavior_impact: undefinedas no behavior change;hidden_hook: nullas no hook. - This means existing quests continue to work with zero runtime errors before Task 10 runs.
Save state migration
schema_versionbumps from2to3SaveState._applyDefaults()already merges new keys safely: old saves that lackbehavior,narrative_phase,suspicion,hidden_hooks_discoveredwill 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_DIRoverride with new schema
Manual test checklist
- Complete Q001 clean fix → confirm
player_ssh_configuredflag set, trust = 53 - Complete Q001 brittle fix → confirm trust penalty,
player_loose_permissionsflag set - After any quest completion → confirm
behaviorobject in/api/state(via debug route) has changed - With
?debug=1→ confirm debug panel visible in frontend - Complete Q001–Q003 → confirm narrative phase advances from
normal_work - Navigate terminal to a hidden anomaly (e.g., unknown user in
/etc/passwd) → confirm/api/debug/hidden-hooksshows new entry - Force phase 3 via debug route → confirm Kowalski pressure profile activates
- Force behavior state to
{ curiosity: 80, obedience: 20, risk: 30 }+ reach resolution phase → confirm EndingEvaluator returnsexposure - Force behavior state to
{ curiosity: 20, obedience: 80, risk: 20 }+ reach resolution phase → confirmcorporate_loop - Run
node tools/content/validate-content.js— zero errors with all existing + updated quests - Run
npm test— all existing tests pass; all new unit tests pass
Content validation checks
- After Task 10: run
validate-content.js --verboseon all 8 updated quests - Confirm all new
narrative_phasevalues are valid enum members - Confirm all new
behavior_impactfields have numeric deltas - Confirm no undeclared world flags introduced
- Confirm all
hidden_hookIDs 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.