@pennyfarthing/core 10.3.0 → 10.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +3 -3
  2. package/package.json +14 -16
  3. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/init.js +3 -0
  5. package/packages/core/dist/cli/commands/init.js.map +1 -1
  6. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  7. package/packages/core/dist/cli/commands/update.js +62 -122
  8. package/packages/core/dist/cli/commands/update.js.map +1 -1
  9. package/packages/core/dist/cli/commands/version-sentinel.test.d.ts +18 -0
  10. package/packages/core/dist/cli/commands/version-sentinel.test.d.ts.map +1 -0
  11. package/packages/core/dist/cli/commands/version-sentinel.test.js +120 -0
  12. package/packages/core/dist/cli/commands/version-sentinel.test.js.map +1 -0
  13. package/packages/core/dist/cli/utils/manifest.d.ts +1 -0
  14. package/packages/core/dist/cli/utils/manifest.d.ts.map +1 -1
  15. package/packages/core/dist/cli/utils/manifest.js.map +1 -1
  16. package/packages/core/dist/cli/utils/migrations.d.ts +88 -0
  17. package/packages/core/dist/cli/utils/migrations.d.ts.map +1 -0
  18. package/packages/core/dist/cli/utils/migrations.js +105 -0
  19. package/packages/core/dist/cli/utils/migrations.js.map +1 -0
  20. package/packages/core/dist/cli/utils/migrations.test.d.ts +23 -0
  21. package/packages/core/dist/cli/utils/migrations.test.d.ts.map +1 -0
  22. package/packages/core/dist/cli/utils/migrations.test.js +319 -0
  23. package/packages/core/dist/cli/utils/migrations.test.js.map +1 -0
  24. package/packages/core/dist/cli/utils/version-sentinel.d.ts +32 -0
  25. package/packages/core/dist/cli/utils/version-sentinel.d.ts.map +1 -0
  26. package/packages/core/dist/cli/utils/version-sentinel.js +49 -0
  27. package/packages/core/dist/cli/utils/version-sentinel.js.map +1 -0
  28. package/packages/core/dist/scripts/generate-spider-report.d.ts.map +1 -1
  29. package/packages/core/dist/scripts/generate-spider-report.js +2 -0
  30. package/packages/core/dist/scripts/generate-spider-report.js.map +1 -1
  31. package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
  32. package/packages/core/dist/scripts/generate-spider.js +1 -0
  33. package/packages/core/dist/scripts/generate-spider.js.map +1 -1
  34. package/packages/core/dist/workflow/context-watch.d.ts +81 -0
  35. package/packages/core/dist/workflow/context-watch.d.ts.map +1 -0
  36. package/packages/core/dist/workflow/context-watch.js +236 -0
  37. package/packages/core/dist/workflow/context-watch.js.map +1 -0
  38. package/packages/core/dist/workflow/context-watch.test.d.ts +2 -0
  39. package/packages/core/dist/workflow/context-watch.test.d.ts.map +1 -0
  40. package/packages/core/dist/workflow/context-watch.test.js +747 -0
  41. package/packages/core/dist/workflow/context-watch.test.js.map +1 -0
  42. package/pennyfarthing-dist/agents/dev.md +47 -0
  43. package/pennyfarthing-dist/personas/themes/fifth-element.yaml +0 -1
  44. package/pennyfarthing-dist/scripts/core/agent-session.sh +0 -0
  45. package/pennyfarthing-dist/scripts/core/check-context.sh +0 -0
  46. package/pennyfarthing-dist/scripts/core/handoff-marker.sh +0 -0
  47. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +0 -0
  48. package/pennyfarthing-dist/scripts/core/prime.sh +0 -0
  49. package/pennyfarthing-dist/scripts/cyclist/is-cyclist.sh +0 -0
  50. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +0 -0
  51. package/pennyfarthing-dist/scripts/git/git-status-all.sh +0 -0
  52. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +0 -0
  53. package/pennyfarthing-dist/scripts/git/release.sh +0 -0
  54. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +0 -0
  55. package/pennyfarthing-dist/scripts/health/drift-detection.sh +0 -0
  56. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +0 -0
  57. package/pennyfarthing-dist/scripts/hooks/context-circuit-breaker.sh +0 -0
  58. package/pennyfarthing-dist/scripts/hooks/context-warning.sh +0 -0
  59. package/pennyfarthing-dist/scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  60. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +0 -0
  61. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +0 -0
  62. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +0 -0
  63. package/pennyfarthing-dist/scripts/hooks/pre-edit-check.sh +0 -0
  64. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +0 -0
  65. package/pennyfarthing-dist/scripts/hooks/question-reflector-check.sh +0 -0
  66. package/pennyfarthing-dist/scripts/hooks/question_reflector_check.py +0 -0
  67. package/pennyfarthing-dist/scripts/hooks/schema-validation.sh +0 -0
  68. package/pennyfarthing-dist/scripts/hooks/session-start.sh +0 -0
  69. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +0 -0
  70. package/pennyfarthing-dist/scripts/hooks/sprint-yaml-validation.sh +0 -0
  71. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +0 -0
  72. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +0 -0
  73. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +0 -0
  74. package/pennyfarthing-dist/scripts/jira/jira-claim-story.sh +0 -0
  75. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +0 -0
  76. package/pennyfarthing-dist/scripts/jira/jira-sync-story.sh +0 -0
  77. package/pennyfarthing-dist/scripts/jira/sync-epic-jira.sh +0 -0
  78. package/pennyfarthing-dist/scripts/lib/background-tasks.sh +0 -0
  79. package/pennyfarthing-dist/scripts/lib/checkpoint.sh +0 -0
  80. package/pennyfarthing-dist/scripts/lib/common.sh +0 -0
  81. package/pennyfarthing-dist/scripts/lib/file-lock.sh +0 -0
  82. package/pennyfarthing-dist/scripts/lib/logging.sh +0 -0
  83. package/pennyfarthing-dist/scripts/lib/retry.sh +0 -0
  84. package/pennyfarthing-dist/scripts/maintenance/migrate-theme-schema.mjs +0 -0
  85. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +0 -0
  86. package/pennyfarthing-dist/scripts/misc/add-short-names.sh +0 -0
  87. package/pennyfarthing-dist/scripts/misc/add_short_names.py +0 -0
  88. package/pennyfarthing-dist/scripts/misc/backlog.sh +0 -0
  89. package/pennyfarthing-dist/scripts/misc/check-status.sh +0 -0
  90. package/pennyfarthing-dist/scripts/misc/find-related-work.sh +0 -0
  91. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +0 -0
  92. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +0 -0
  93. package/pennyfarthing-dist/scripts/misc/migrate-bmad-workflow.sh +0 -0
  94. package/pennyfarthing-dist/scripts/misc/migrate_bmad_workflow.py +0 -0
  95. package/pennyfarthing-dist/scripts/misc/repo-scan.sh +0 -0
  96. package/pennyfarthing-dist/scripts/misc/repo-utils.sh +0 -0
  97. package/pennyfarthing-dist/scripts/misc/run-ci.sh +0 -0
  98. package/pennyfarthing-dist/scripts/misc/run-timestamp.sh +0 -0
  99. package/pennyfarthing-dist/scripts/misc/session-cleanup.sh +0 -0
  100. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +0 -0
  101. package/pennyfarthing-dist/scripts/misc/statusline.sh +2 -0
  102. package/pennyfarthing-dist/scripts/misc/uninstall.sh +0 -0
  103. package/pennyfarthing-dist/scripts/misc/validate-subagent-frontmatter.sh +0 -0
  104. package/pennyfarthing-dist/scripts/portraits/generate-portraits.sh +0 -0
  105. package/pennyfarthing-dist/scripts/story/create-story.sh +0 -0
  106. package/pennyfarthing-dist/scripts/story/size-story.sh +0 -0
  107. package/pennyfarthing-dist/scripts/story/story-template.sh +0 -0
  108. package/pennyfarthing-dist/scripts/tests/check.test.sh +0 -0
  109. package/pennyfarthing-dist/scripts/tests/dev-story-workflow-import.test.sh +0 -0
  110. package/pennyfarthing-dist/scripts/tests/epics-and-stories-workflow-import.test.sh +0 -0
  111. package/pennyfarthing-dist/scripts/tests/handoff-phase-update.test.sh +0 -0
  112. package/pennyfarthing-dist/scripts/tests/implementation-readiness-workflow-import.test.sh +0 -0
  113. package/pennyfarthing-dist/scripts/tests/migrate-bmad-workflow.test.sh +0 -0
  114. package/pennyfarthing-dist/scripts/tests/prd-workflow-import.test.sh +0 -0
  115. package/pennyfarthing-dist/scripts/tests/project-context-workflow-import.test.sh +0 -0
  116. package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +0 -0
  117. package/pennyfarthing-dist/scripts/tests/test-drift-detection.sh +0 -0
  118. package/pennyfarthing-dist/scripts/tests/test-post-merge-hook.sh +0 -0
  119. package/pennyfarthing-dist/scripts/tests/test-session-checkpoint.sh +0 -0
  120. package/pennyfarthing-dist/scripts/tests/test-solo-command.sh +0 -0
  121. package/pennyfarthing-dist/scripts/tests/ux-design-workflow-import.test.sh +0 -0
  122. package/pennyfarthing-dist/scripts/theme/list-themes.sh +0 -0
  123. package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +0 -0
  124. package/pennyfarthing-dist/scripts/workflow/check.py +0 -0
  125. package/pennyfarthing-dist/scripts/workflow/check.sh +0 -0
  126. package/pennyfarthing-dist/scripts/workflow/complete-step.py +0 -0
  127. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +0 -0
  128. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +0 -0
  129. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +0 -0
  130. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +0 -0
  131. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +0 -0
  132. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +0 -0
  133. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +0 -0
  134. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +0 -0
  135. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +0 -0
  136. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +0 -0
  137. package/pennyfarthing-dist/skills/story/scripts/create-story.sh +0 -0
  138. package/pennyfarthing-dist/skills/story/scripts/size-story.sh +0 -0
  139. package/pennyfarthing-dist/skills/story/scripts/story-template.sh +0 -0
  140. package/pennyfarthing-dist/skills/workflow/scripts/list-workflows.sh +0 -0
  141. package/pennyfarthing-dist/skills/workflow/scripts/resume-workflow.sh +0 -0
  142. package/pennyfarthing-dist/skills/workflow/scripts/show-workflow.sh +0 -0
  143. package/pennyfarthing-dist/skills/workflow/scripts/start-workflow.sh +0 -0
  144. package/pennyfarthing-dist/skills/workflow/scripts/workflow-status.sh +0 -0
  145. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
  147. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/bikerack/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/bikerack/__pycache__/__main__.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
  154. package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
  155. package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
  156. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  157. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  158. package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
  159. package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
  160. package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
  162. package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
  163. package/pennyfarthing_scripts/hooks/cyclist-pretooluse-hook.sh +0 -0
  164. package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
  165. package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
  166. package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
  167. package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  169. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  170. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  171. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  172. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  173. package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
  174. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  175. package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
  176. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  177. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  178. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  179. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  180. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  181. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  182. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  183. package/pennyfarthing_scripts/prime/__pycache__/version_sentinel.cpython-314.pyc +0 -0
  184. package/pennyfarthing_scripts/prime/version_sentinel.py +104 -0
  185. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  186. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  187. package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
  188. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  189. package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
  190. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  191. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  192. package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
  193. package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
  194. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  195. package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
  196. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  197. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  198. package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
  199. package/pennyfarthing_scripts/sprint/archive_epic.py +9 -1
  200. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  201. package/pennyfarthing_scripts/tests/__pycache__/test_version_sentinel.cpython-314-pytest-9.0.2.pyc +0 -0
  202. package/pennyfarthing_scripts/tests/test_version_sentinel.py +126 -0
  203. package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
  204. package/pennyfarthing_scripts/validate/__pycache__/__init__.cpython-314.pyc +0 -0
  205. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  206. package/scripts/README.md +0 -41
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Tests for Story 95-6: Context-watch Observation Scope
3
+ *
4
+ * RED state tests for periodic conversation summary delivery to backseat agent.
5
+ * These tests cover all acceptance criteria:
6
+ *
7
+ * AC1: Backseat receives periodic conversation summaries at configurable turn intervals
8
+ * AC2: Default interval: every 5 tool calls
9
+ * AC3: Summaries capture primary agent's current focus, decisions, and approach
10
+ * AC4: Summary generation does not block primary agent's conversation flow
11
+ * AC5: Combined token overhead stays under 25% per phase
12
+ * AC6: Context accumulates — later summaries reference earlier ones
13
+ *
14
+ * Run with: npm test
15
+ */
16
+ import { describe, it, beforeEach, afterEach } from 'node:test';
17
+ import assert from 'node:assert';
18
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ // Import the module under test (does not exist yet — will cause import failure)
22
+ import { incrementTurnCounter, readTurnCounter, resetTurnCounter, shouldTriggerSummary, writeContextSnapshot, readContextSnapshot, startContextWatcher, stopContextWatcher, } from './context-watch.js';
23
+ // Import observation writer for integration tests
24
+ import { initObservationFile, parseObservationFile, } from './observation-writer.js';
25
+ // Get directory for test fixtures
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const TEST_DIR = join(__dirname, '__test_context_watch__');
28
+ const SESSION_DIR = join(TEST_DIR, '.session');
29
+ // =============================================================================
30
+ // Test Fixtures
31
+ // =============================================================================
32
+ const DEFAULT_POLL_MS = 100; // Fast for testing
33
+ const DEFAULT_CONFIG = {
34
+ sessionDir: SESSION_DIR,
35
+ storyId: '95-6',
36
+ agent: 'architect',
37
+ persona: 'Will Bailey',
38
+ phase: 'implement',
39
+ pollIntervalMs: DEFAULT_POLL_MS,
40
+ turnInterval: 5,
41
+ };
42
+ const OBS_CONFIG = {
43
+ storyId: '95-6',
44
+ agent: 'architect',
45
+ persona: 'Will Bailey',
46
+ phase: 'implement',
47
+ sessionDir: SESSION_DIR,
48
+ };
49
+ function counterPath() {
50
+ return join(SESSION_DIR, '.tandem-turn-counter');
51
+ }
52
+ function snapshotPath() {
53
+ return join(SESSION_DIR, `${DEFAULT_CONFIG.storyId}-tandem-context.md`);
54
+ }
55
+ // =============================================================================
56
+ // Setup / Teardown
57
+ // =============================================================================
58
+ describe('95-6: Context-watch Observation Scope', () => {
59
+ beforeEach(() => {
60
+ if (existsSync(TEST_DIR)) {
61
+ rmSync(TEST_DIR, { recursive: true });
62
+ }
63
+ mkdirSync(SESSION_DIR, { recursive: true });
64
+ });
65
+ afterEach(() => {
66
+ if (existsSync(TEST_DIR)) {
67
+ rmSync(TEST_DIR, { recursive: true });
68
+ }
69
+ });
70
+ // ===========================================================================
71
+ // AC1: Backseat receives periodic conversation summaries at configurable
72
+ // turn intervals
73
+ // ===========================================================================
74
+ describe('AC1: Periodic summaries at configurable intervals', () => {
75
+ it('should increment turn counter on each call', () => {
76
+ const r1 = incrementTurnCounter(SESSION_DIR);
77
+ assert.ok(r1.success, `incrementTurnCounter failed: ${r1.error}`);
78
+ assert.strictEqual(r1.data, 1, 'First increment should return 1');
79
+ const r2 = incrementTurnCounter(SESSION_DIR);
80
+ assert.ok(r2.success);
81
+ assert.strictEqual(r2.data, 2, 'Second increment should return 2');
82
+ const r3 = incrementTurnCounter(SESSION_DIR);
83
+ assert.ok(r3.success);
84
+ assert.strictEqual(r3.data, 3, 'Third increment should return 3');
85
+ });
86
+ it('should read current turn counter value', () => {
87
+ incrementTurnCounter(SESSION_DIR);
88
+ incrementTurnCounter(SESSION_DIR);
89
+ incrementTurnCounter(SESSION_DIR);
90
+ const result = readTurnCounter(SESSION_DIR);
91
+ assert.ok(result.success);
92
+ assert.strictEqual(result.data, 3);
93
+ });
94
+ it('should return 0 when counter file does not exist', () => {
95
+ const result = readTurnCounter(SESSION_DIR);
96
+ assert.ok(result.success);
97
+ assert.strictEqual(result.data, 0, 'No counter file should return 0');
98
+ });
99
+ it('should reset turn counter to 0', () => {
100
+ incrementTurnCounter(SESSION_DIR);
101
+ incrementTurnCounter(SESSION_DIR);
102
+ incrementTurnCounter(SESSION_DIR);
103
+ const resetResult = resetTurnCounter(SESSION_DIR);
104
+ assert.ok(resetResult.success);
105
+ const readResult = readTurnCounter(SESSION_DIR);
106
+ assert.ok(readResult.success);
107
+ assert.strictEqual(readResult.data, 0, 'Counter should be 0 after reset');
108
+ });
109
+ it('should trigger summary at configurable interval', () => {
110
+ // With interval=3, should trigger at 3, 6, 9...
111
+ assert.strictEqual(shouldTriggerSummary(1, 3), false);
112
+ assert.strictEqual(shouldTriggerSummary(2, 3), false);
113
+ assert.strictEqual(shouldTriggerSummary(3, 3), true, 'Should trigger at turn 3 with interval 3');
114
+ assert.strictEqual(shouldTriggerSummary(4, 3), false);
115
+ assert.strictEqual(shouldTriggerSummary(5, 3), false);
116
+ assert.strictEqual(shouldTriggerSummary(6, 3), true, 'Should trigger at turn 6 with interval 3');
117
+ });
118
+ it('should not trigger at turn 0', () => {
119
+ assert.strictEqual(shouldTriggerSummary(0, 5), false);
120
+ });
121
+ it('should write context snapshot when watcher triggers at interval', async () => {
122
+ const initResult = initObservationFile(OBS_CONFIG);
123
+ assert.ok(initResult.success);
124
+ assert.ok(initResult.data);
125
+ // Create a session file with some content for the watcher to read
126
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
127
+ writeFileSync(sessionFile, '# Session\n## Dev Assessment\nWorking on feature X\n');
128
+ // Simulate enough turns to trigger (interval=5, so 5 increments)
129
+ for (let i = 0; i < 5; i++) {
130
+ incrementTurnCounter(SESSION_DIR);
131
+ }
132
+ const watchResult = await startContextWatcher({
133
+ ...DEFAULT_CONFIG,
134
+ observationFilePath: initResult.data.path,
135
+ sessionFilePath: sessionFile,
136
+ });
137
+ assert.ok(watchResult.success, `startContextWatcher failed: ${watchResult.error}`);
138
+ assert.ok(watchResult.data);
139
+ // Wait for at least one poll cycle
140
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
141
+ await stopContextWatcher(watchResult.data);
142
+ // Should have written at least one context observation
143
+ const parsed = parseObservationFile(initResult.data.path);
144
+ assert.ok(parsed.success);
145
+ assert.ok(parsed.data);
146
+ assert.ok(parsed.data.entries.length > 0, 'Should write observation when turn counter hits interval');
147
+ });
148
+ });
149
+ // ===========================================================================
150
+ // AC2: Default interval: every 5 tool calls
151
+ // ===========================================================================
152
+ describe('AC2: Default interval of 5', () => {
153
+ it('should use default interval of 5 when not specified', () => {
154
+ // Turns 1-4 should not trigger
155
+ assert.strictEqual(shouldTriggerSummary(1, 5), false);
156
+ assert.strictEqual(shouldTriggerSummary(2, 5), false);
157
+ assert.strictEqual(shouldTriggerSummary(3, 5), false);
158
+ assert.strictEqual(shouldTriggerSummary(4, 5), false);
159
+ // Turn 5 should trigger
160
+ assert.strictEqual(shouldTriggerSummary(5, 5), true);
161
+ });
162
+ it('startContextWatcher should default to turnInterval 5', async () => {
163
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
164
+ writeFileSync(sessionFile, '# Session\nContent here\n');
165
+ const initResult = initObservationFile(OBS_CONFIG);
166
+ assert.ok(initResult.success);
167
+ assert.ok(initResult.data);
168
+ // Set counter to 4 (not yet at 5)
169
+ for (let i = 0; i < 4; i++) {
170
+ incrementTurnCounter(SESSION_DIR);
171
+ }
172
+ const watchResult = await startContextWatcher({
173
+ sessionDir: SESSION_DIR,
174
+ storyId: '95-6',
175
+ agent: 'architect',
176
+ persona: 'Will Bailey',
177
+ phase: 'implement',
178
+ pollIntervalMs: DEFAULT_POLL_MS,
179
+ // turnInterval NOT specified — should default to 5
180
+ observationFilePath: initResult.data.path,
181
+ sessionFilePath: sessionFile,
182
+ });
183
+ assert.ok(watchResult.success);
184
+ assert.ok(watchResult.data);
185
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 3));
186
+ await stopContextWatcher(watchResult.data);
187
+ // At turn 4, should NOT have triggered (not at interval yet)
188
+ const parsed = parseObservationFile(initResult.data.path);
189
+ assert.ok(parsed.success);
190
+ assert.ok(parsed.data);
191
+ assert.strictEqual(parsed.data.entries.length, 0, 'Should not trigger at turn 4 with default interval 5');
192
+ });
193
+ it('should trigger at exactly turn 5 with default interval', async () => {
194
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
195
+ writeFileSync(sessionFile, '# Session\nDev is implementing feature X\n');
196
+ const initResult = initObservationFile(OBS_CONFIG);
197
+ assert.ok(initResult.success);
198
+ assert.ok(initResult.data);
199
+ // Set counter to exactly 5
200
+ for (let i = 0; i < 5; i++) {
201
+ incrementTurnCounter(SESSION_DIR);
202
+ }
203
+ const watchResult = await startContextWatcher({
204
+ sessionDir: SESSION_DIR,
205
+ storyId: '95-6',
206
+ agent: 'architect',
207
+ persona: 'Will Bailey',
208
+ phase: 'implement',
209
+ pollIntervalMs: DEFAULT_POLL_MS,
210
+ observationFilePath: initResult.data.path,
211
+ sessionFilePath: sessionFile,
212
+ });
213
+ assert.ok(watchResult.success);
214
+ assert.ok(watchResult.data);
215
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
216
+ await stopContextWatcher(watchResult.data);
217
+ const parsed = parseObservationFile(initResult.data.path);
218
+ assert.ok(parsed.success);
219
+ assert.ok(parsed.data);
220
+ assert.ok(parsed.data.entries.length > 0, 'Should trigger at turn 5 with default interval');
221
+ });
222
+ });
223
+ // ===========================================================================
224
+ // AC3: Summaries capture primary agent's current focus, decisions, approach
225
+ // ===========================================================================
226
+ describe('AC3: Summary content quality', () => {
227
+ it('should write context snapshot from session file content', () => {
228
+ const sessionContent = [
229
+ '# Session: 95-6',
230
+ '## Dev Assessment',
231
+ 'Working on migrating NotificationService to React hooks.',
232
+ 'Completed useNotification hook.',
233
+ 'Now integrating with NotificationPanel.',
234
+ ].join('\n');
235
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
236
+ writeFileSync(sessionFile, sessionContent);
237
+ const result = writeContextSnapshot({
238
+ sessionDir: SESSION_DIR,
239
+ storyId: '95-6',
240
+ sessionFilePath: sessionFile,
241
+ turnCount: 5,
242
+ });
243
+ assert.ok(result.success, `writeContextSnapshot failed: ${result.error}`);
244
+ assert.ok(result.data, 'Should return snapshot data');
245
+ assert.ok(result.data.content.length > 0, 'Snapshot content should not be empty');
246
+ });
247
+ it('context snapshot should include session content', () => {
248
+ const sessionContent = '# Session\n## Status\nRefactoring authentication module.\n';
249
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
250
+ writeFileSync(sessionFile, sessionContent);
251
+ const result = writeContextSnapshot({
252
+ sessionDir: SESSION_DIR,
253
+ storyId: '95-6',
254
+ sessionFilePath: sessionFile,
255
+ turnCount: 10,
256
+ });
257
+ assert.ok(result.success);
258
+ assert.ok(result.data);
259
+ // The snapshot should contain information derived from the session
260
+ assert.ok(result.data.content.length > 10, 'Snapshot should contain substantive content from session');
261
+ });
262
+ it('context snapshot should include turn count', () => {
263
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
264
+ writeFileSync(sessionFile, '# Session\nSome content\n');
265
+ const result = writeContextSnapshot({
266
+ sessionDir: SESSION_DIR,
267
+ storyId: '95-6',
268
+ sessionFilePath: sessionFile,
269
+ turnCount: 15,
270
+ });
271
+ assert.ok(result.success);
272
+ assert.ok(result.data);
273
+ // Snapshot should reference turn number
274
+ assert.ok(result.data.content.includes('15'), 'Snapshot should reference current turn count');
275
+ });
276
+ it('observations should use context-watch trigger type', async () => {
277
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
278
+ writeFileSync(sessionFile, '# Session\nDev is working on API endpoint\n');
279
+ const initResult = initObservationFile(OBS_CONFIG);
280
+ assert.ok(initResult.success);
281
+ assert.ok(initResult.data);
282
+ // Set counter to trigger (5)
283
+ for (let i = 0; i < 5; i++) {
284
+ incrementTurnCounter(SESSION_DIR);
285
+ }
286
+ const watchResult = await startContextWatcher({
287
+ ...DEFAULT_CONFIG,
288
+ observationFilePath: initResult.data.path,
289
+ sessionFilePath: sessionFile,
290
+ });
291
+ assert.ok(watchResult.success);
292
+ assert.ok(watchResult.data);
293
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
294
+ await stopContextWatcher(watchResult.data);
295
+ const parsed = parseObservationFile(initResult.data.path);
296
+ assert.ok(parsed.success);
297
+ assert.ok(parsed.data);
298
+ assert.ok(parsed.data.entries.length > 0, 'Should have observation entry');
299
+ const entry = parsed.data.entries[0];
300
+ assert.strictEqual(entry.triggerType, 'context-watch', 'Trigger type should be context-watch');
301
+ });
302
+ it('trigger detail should include turn information', async () => {
303
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
304
+ writeFileSync(sessionFile, '# Session\nContent\n');
305
+ const initResult = initObservationFile(OBS_CONFIG);
306
+ assert.ok(initResult.success);
307
+ assert.ok(initResult.data);
308
+ for (let i = 0; i < 5; i++) {
309
+ incrementTurnCounter(SESSION_DIR);
310
+ }
311
+ const watchResult = await startContextWatcher({
312
+ ...DEFAULT_CONFIG,
313
+ observationFilePath: initResult.data.path,
314
+ sessionFilePath: sessionFile,
315
+ });
316
+ assert.ok(watchResult.success);
317
+ assert.ok(watchResult.data);
318
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
319
+ await stopContextWatcher(watchResult.data);
320
+ const parsed = parseObservationFile(initResult.data.path);
321
+ assert.ok(parsed.success);
322
+ assert.ok(parsed.data);
323
+ assert.ok(parsed.data.entries.length > 0);
324
+ const entry = parsed.data.entries[0];
325
+ assert.ok(entry.triggerDetail.includes('turn') || entry.triggerDetail.includes('5'), `Trigger detail should reference turn info, got: "${entry.triggerDetail}"`);
326
+ });
327
+ });
328
+ // ===========================================================================
329
+ // AC4: Summary generation does not block primary agent's conversation flow
330
+ // ===========================================================================
331
+ describe('AC4: Non-blocking', () => {
332
+ it('incrementTurnCounter should complete within 10ms', () => {
333
+ mkdirSync(SESSION_DIR, { recursive: true });
334
+ const start = Date.now();
335
+ const result = incrementTurnCounter(SESSION_DIR);
336
+ const elapsed = Date.now() - start;
337
+ assert.ok(result.success);
338
+ assert.ok(elapsed < 10, `incrementTurnCounter took ${elapsed}ms, expected < 10ms`);
339
+ });
340
+ it('shouldTriggerSummary should be a pure function (no I/O)', () => {
341
+ const start = Date.now();
342
+ for (let i = 0; i < 10000; i++) {
343
+ shouldTriggerSummary(i, 5);
344
+ }
345
+ const elapsed = Date.now() - start;
346
+ assert.ok(elapsed < 50, `10000 calls to shouldTriggerSummary took ${elapsed}ms — should be pure`);
347
+ });
348
+ it('writeContextSnapshot should complete within 50ms', () => {
349
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
350
+ writeFileSync(sessionFile, '# Session\nContent for snapshot\n');
351
+ const start = Date.now();
352
+ const result = writeContextSnapshot({
353
+ sessionDir: SESSION_DIR,
354
+ storyId: '95-6',
355
+ sessionFilePath: sessionFile,
356
+ turnCount: 5,
357
+ });
358
+ const elapsed = Date.now() - start;
359
+ assert.ok(result.success);
360
+ assert.ok(elapsed < 50, `writeContextSnapshot took ${elapsed}ms, expected < 50ms`);
361
+ });
362
+ it('startContextWatcher should return handle immediately', async () => {
363
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
364
+ writeFileSync(sessionFile, '# Session\nContent\n');
365
+ const start = Date.now();
366
+ const result = await startContextWatcher({
367
+ ...DEFAULT_CONFIG,
368
+ sessionFilePath: sessionFile,
369
+ });
370
+ const elapsed = Date.now() - start;
371
+ assert.ok(result.success, `startContextWatcher failed: ${result.error}`);
372
+ assert.ok(result.data);
373
+ assert.ok(elapsed < 1000, `startContextWatcher took ${elapsed}ms, expected < 1000ms`);
374
+ if (result.data) {
375
+ await stopContextWatcher(result.data);
376
+ }
377
+ });
378
+ it('incrementTurnCounter should not throw on missing session dir', () => {
379
+ const result = incrementTurnCounter('/nonexistent/session/dir');
380
+ assert.strictEqual(result.success, false);
381
+ assert.ok(result.error, 'Should have error message');
382
+ });
383
+ });
384
+ // ===========================================================================
385
+ // AC5: Combined token overhead stays under 25% per phase
386
+ // ===========================================================================
387
+ describe('AC5: Token overhead budget', () => {
388
+ it('context snapshot should be within size budget', () => {
389
+ // A large session file
390
+ const sessionContent = 'Line of session content.\n'.repeat(100);
391
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
392
+ writeFileSync(sessionFile, sessionContent);
393
+ const result = writeContextSnapshot({
394
+ sessionDir: SESSION_DIR,
395
+ storyId: '95-6',
396
+ sessionFilePath: sessionFile,
397
+ turnCount: 10,
398
+ });
399
+ assert.ok(result.success);
400
+ assert.ok(result.data);
401
+ // Snapshot content should be bounded — not a verbatim copy of the whole session
402
+ // 2000 chars ≈ 500 tokens, reasonable for a context summary
403
+ assert.ok(result.data.content.length <= 2000, `Snapshot should be bounded, got ${result.data.content.length} chars`);
404
+ });
405
+ it('context snapshot should be smaller than source session content', () => {
406
+ // Large session file
407
+ const sessionContent = 'Detailed session content with implementation notes.\n'.repeat(200);
408
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
409
+ writeFileSync(sessionFile, sessionContent);
410
+ const result = writeContextSnapshot({
411
+ sessionDir: SESSION_DIR,
412
+ storyId: '95-6',
413
+ sessionFilePath: sessionFile,
414
+ turnCount: 25,
415
+ });
416
+ assert.ok(result.success);
417
+ assert.ok(result.data);
418
+ assert.ok(result.data.content.length < sessionContent.length, 'Snapshot should be smaller than full session content');
419
+ });
420
+ it('multiple summaries over a phase should stay within token budget', () => {
421
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
422
+ writeFileSync(sessionFile, 'Session content for budget test.\n'.repeat(50));
423
+ // Simulate 10 summaries (50 turns / interval 5 = 10 summaries)
424
+ let totalChars = 0;
425
+ for (let turn = 5; turn <= 50; turn += 5) {
426
+ const result = writeContextSnapshot({
427
+ sessionDir: SESSION_DIR,
428
+ storyId: '95-6',
429
+ sessionFilePath: sessionFile,
430
+ turnCount: turn,
431
+ });
432
+ assert.ok(result.success);
433
+ assert.ok(result.data);
434
+ totalChars += result.data.content.length;
435
+ }
436
+ // 10 summaries × ~500 tokens max = ~5000 tokens ≈ 20000 chars
437
+ // 25% of a 100K token phase = 25K tokens = ~100K chars
438
+ // So 20K chars is well within budget
439
+ assert.ok(totalChars < 30000, `Total snapshot chars across 10 summaries: ${totalChars}, should be < 30000`);
440
+ });
441
+ it('writeContextSnapshot should report estimated token count', () => {
442
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
443
+ writeFileSync(sessionFile, '# Session\nSome content for estimation.\n');
444
+ const result = writeContextSnapshot({
445
+ sessionDir: SESSION_DIR,
446
+ storyId: '95-6',
447
+ sessionFilePath: sessionFile,
448
+ turnCount: 5,
449
+ });
450
+ assert.ok(result.success);
451
+ assert.ok(result.data);
452
+ assert.ok(typeof result.data.estimatedTokens === 'number', 'Should include estimated token count');
453
+ assert.ok(result.data.estimatedTokens > 0, 'Token estimate should be positive');
454
+ });
455
+ });
456
+ // ===========================================================================
457
+ // AC6: Context accumulates — later summaries reference earlier ones
458
+ // ===========================================================================
459
+ describe('AC6: Context accumulation', () => {
460
+ it('readContextSnapshot should return previous snapshot', () => {
461
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
462
+ writeFileSync(sessionFile, '# Session\nFirst phase of work\n');
463
+ // Write first snapshot
464
+ writeContextSnapshot({
465
+ sessionDir: SESSION_DIR,
466
+ storyId: '95-6',
467
+ sessionFilePath: sessionFile,
468
+ turnCount: 5,
469
+ });
470
+ // Read it back
471
+ const readResult = readContextSnapshot(SESSION_DIR, '95-6');
472
+ assert.ok(readResult.success, `readContextSnapshot failed: ${readResult.error}`);
473
+ assert.ok(readResult.data);
474
+ assert.ok(readResult.data.content.length > 0, 'Should return previous snapshot content');
475
+ });
476
+ it('readContextSnapshot should return empty for no prior snapshot', () => {
477
+ const result = readContextSnapshot(SESSION_DIR, '95-6');
478
+ assert.ok(result.success);
479
+ assert.ok(result.data);
480
+ assert.strictEqual(result.data.content, '', 'No prior snapshot should return empty content');
481
+ });
482
+ it('later snapshots should include reference to prior context', () => {
483
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
484
+ // First snapshot at turn 5
485
+ writeFileSync(sessionFile, '# Session\nDev is setting up authentication module\n');
486
+ writeContextSnapshot({
487
+ sessionDir: SESSION_DIR,
488
+ storyId: '95-6',
489
+ sessionFilePath: sessionFile,
490
+ turnCount: 5,
491
+ });
492
+ // Second snapshot at turn 10 — session has more content
493
+ writeFileSync(sessionFile, '# Session\nDev is setting up authentication module\n## Progress\nJWT implementation complete. Now adding refresh tokens.\n');
494
+ const result = writeContextSnapshot({
495
+ sessionDir: SESSION_DIR,
496
+ storyId: '95-6',
497
+ sessionFilePath: sessionFile,
498
+ turnCount: 10,
499
+ });
500
+ assert.ok(result.success);
501
+ assert.ok(result.data);
502
+ // The second snapshot should be aware it follows a prior one
503
+ // (either by including turn range or referencing prior summary)
504
+ assert.ok(result.data.content.includes('10') || result.data.content.includes('turn'), 'Later snapshot should reference its turn context');
505
+ });
506
+ it('watcher should produce multiple observations across intervals', async () => {
507
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
508
+ writeFileSync(sessionFile, '# Session\nDev working on feature\n');
509
+ const initResult = initObservationFile(OBS_CONFIG);
510
+ assert.ok(initResult.success);
511
+ assert.ok(initResult.data);
512
+ // Set counter to 5 (first trigger)
513
+ for (let i = 0; i < 5; i++) {
514
+ incrementTurnCounter(SESSION_DIR);
515
+ }
516
+ const watchResult = await startContextWatcher({
517
+ ...DEFAULT_CONFIG,
518
+ observationFilePath: initResult.data.path,
519
+ sessionFilePath: sessionFile,
520
+ });
521
+ assert.ok(watchResult.success);
522
+ assert.ok(watchResult.data);
523
+ // Wait for first observation
524
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
525
+ // Increment to 10 (second trigger)
526
+ for (let i = 0; i < 5; i++) {
527
+ incrementTurnCounter(SESSION_DIR);
528
+ }
529
+ // Update session to simulate progress
530
+ writeFileSync(sessionFile, '# Session\nDev working on feature\n## Update\nTests now passing\n');
531
+ // Wait for second observation
532
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
533
+ await stopContextWatcher(watchResult.data);
534
+ const parsed = parseObservationFile(initResult.data.path);
535
+ assert.ok(parsed.success);
536
+ assert.ok(parsed.data);
537
+ assert.ok(parsed.data.entries.length >= 2, `Should have at least 2 observations for 2 intervals, got ${parsed.data.entries.length}`);
538
+ });
539
+ it('accumulated observations should not duplicate content', async () => {
540
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
541
+ writeFileSync(sessionFile, '# Session\nUnique content for dedup test\n');
542
+ const initResult = initObservationFile(OBS_CONFIG);
543
+ assert.ok(initResult.success);
544
+ assert.ok(initResult.data);
545
+ // First trigger at turn 5
546
+ for (let i = 0; i < 5; i++) {
547
+ incrementTurnCounter(SESSION_DIR);
548
+ }
549
+ const watchResult = await startContextWatcher({
550
+ ...DEFAULT_CONFIG,
551
+ observationFilePath: initResult.data.path,
552
+ sessionFilePath: sessionFile,
553
+ });
554
+ assert.ok(watchResult.success);
555
+ assert.ok(watchResult.data);
556
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
557
+ // Trigger again at turn 10 — same session content
558
+ for (let i = 0; i < 5; i++) {
559
+ incrementTurnCounter(SESSION_DIR);
560
+ }
561
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
562
+ await stopContextWatcher(watchResult.data);
563
+ const parsed = parseObservationFile(initResult.data.path);
564
+ assert.ok(parsed.success);
565
+ assert.ok(parsed.data);
566
+ if (parsed.data.entries.length >= 2) {
567
+ // Second observation should not be identical to first
568
+ assert.notStrictEqual(parsed.data.entries[0].observation, parsed.data.entries[1].observation, 'Accumulated observations should differ (even if session unchanged, turn number differs)');
569
+ }
570
+ });
571
+ });
572
+ // ===========================================================================
573
+ // Result objects per framework pattern
574
+ // ===========================================================================
575
+ describe('Result objects', () => {
576
+ it('incrementTurnCounter should return {success, data?, error?}', () => {
577
+ const result = incrementTurnCounter(SESSION_DIR);
578
+ assert.ok(typeof result === 'object');
579
+ assert.ok('success' in result, 'Result must have success field');
580
+ assert.ok('data' in result || result.success === false);
581
+ });
582
+ it('readTurnCounter should return {success, data?, error?}', () => {
583
+ const result = readTurnCounter(SESSION_DIR);
584
+ assert.ok(typeof result === 'object');
585
+ assert.ok('success' in result);
586
+ });
587
+ it('writeContextSnapshot should return {success, data?, error?}', () => {
588
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
589
+ writeFileSync(sessionFile, '# Session\nContent\n');
590
+ const result = writeContextSnapshot({
591
+ sessionDir: SESSION_DIR,
592
+ storyId: '95-6',
593
+ sessionFilePath: sessionFile,
594
+ turnCount: 5,
595
+ });
596
+ assert.ok(typeof result === 'object');
597
+ assert.ok('success' in result);
598
+ });
599
+ it('startContextWatcher should return {success, data?: ContextWatchHandle, error?}', async () => {
600
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
601
+ writeFileSync(sessionFile, '# Session\nContent\n');
602
+ const result = await startContextWatcher({
603
+ ...DEFAULT_CONFIG,
604
+ sessionFilePath: sessionFile,
605
+ });
606
+ assert.ok(typeof result === 'object');
607
+ assert.ok('success' in result);
608
+ if (result.success && result.data) {
609
+ assert.ok(typeof result.data === 'object');
610
+ await stopContextWatcher(result.data);
611
+ }
612
+ });
613
+ it('stopContextWatcher should return {success, error?}', async () => {
614
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
615
+ writeFileSync(sessionFile, '# Session\nContent\n');
616
+ const watchResult = await startContextWatcher({
617
+ ...DEFAULT_CONFIG,
618
+ sessionFilePath: sessionFile,
619
+ });
620
+ assert.ok(watchResult.success);
621
+ assert.ok(watchResult.data);
622
+ const stopResult = await stopContextWatcher(watchResult.data);
623
+ assert.ok(typeof stopResult === 'object');
624
+ assert.ok('success' in stopResult);
625
+ assert.ok(stopResult.success);
626
+ });
627
+ });
628
+ // ===========================================================================
629
+ // Error resilience
630
+ // ===========================================================================
631
+ describe('Error resilience', () => {
632
+ it('incrementTurnCounter should handle missing session dir', () => {
633
+ const result = incrementTurnCounter('/nonexistent/session/dir');
634
+ assert.strictEqual(result.success, false);
635
+ assert.ok(result.error);
636
+ });
637
+ it('writeContextSnapshot should handle missing session file', () => {
638
+ const result = writeContextSnapshot({
639
+ sessionDir: SESSION_DIR,
640
+ storyId: '95-6',
641
+ sessionFilePath: '/nonexistent/session.md',
642
+ turnCount: 5,
643
+ });
644
+ assert.strictEqual(result.success, false);
645
+ assert.ok(result.error);
646
+ });
647
+ it('watcher should continue polling after snapshot write error', async () => {
648
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
649
+ writeFileSync(sessionFile, '# Session\nContent\n');
650
+ // Set counter to trigger
651
+ for (let i = 0; i < 5; i++) {
652
+ incrementTurnCounter(SESSION_DIR);
653
+ }
654
+ // Start watcher with bad observation file path
655
+ const watchResult = await startContextWatcher({
656
+ ...DEFAULT_CONFIG,
657
+ observationFilePath: '/nonexistent/obs.md',
658
+ sessionFilePath: sessionFile,
659
+ });
660
+ assert.ok(watchResult.success, 'Watcher should start even with bad observation path');
661
+ assert.ok(watchResult.data);
662
+ // Wait for poll cycles — watcher should NOT crash
663
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
664
+ const stopResult = await stopContextWatcher(watchResult.data);
665
+ assert.ok(stopResult.success, 'Should stop cleanly even after write errors');
666
+ });
667
+ it('stopContextWatcher should be idempotent', async () => {
668
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
669
+ writeFileSync(sessionFile, '# Session\nContent\n');
670
+ const watchResult = await startContextWatcher({
671
+ ...DEFAULT_CONFIG,
672
+ sessionFilePath: sessionFile,
673
+ });
674
+ assert.ok(watchResult.success);
675
+ assert.ok(watchResult.data);
676
+ const stop1 = await stopContextWatcher(watchResult.data);
677
+ assert.ok(stop1.success);
678
+ const stop2 = await stopContextWatcher(watchResult.data);
679
+ assert.ok(stop2.success, 'Second stop should also succeed (idempotent)');
680
+ });
681
+ it('readContextSnapshot should handle missing snapshot file', () => {
682
+ const result = readContextSnapshot(SESSION_DIR, '95-6');
683
+ assert.ok(result.success, 'Missing snapshot is not an error — just empty');
684
+ assert.ok(result.data);
685
+ assert.strictEqual(result.data.content, '');
686
+ });
687
+ });
688
+ // ===========================================================================
689
+ // Edge cases
690
+ // ===========================================================================
691
+ describe('Edge cases', () => {
692
+ it('should handle empty session file', () => {
693
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
694
+ writeFileSync(sessionFile, '');
695
+ const result = writeContextSnapshot({
696
+ sessionDir: SESSION_DIR,
697
+ storyId: '95-6',
698
+ sessionFilePath: sessionFile,
699
+ turnCount: 5,
700
+ });
701
+ assert.ok(result.success, 'Empty session should not cause error');
702
+ assert.ok(result.data);
703
+ });
704
+ it('should handle very large session file', () => {
705
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
706
+ const largeContent = 'A line of session content with details.\n'.repeat(5000);
707
+ writeFileSync(sessionFile, largeContent);
708
+ const start = Date.now();
709
+ const result = writeContextSnapshot({
710
+ sessionDir: SESSION_DIR,
711
+ storyId: '95-6',
712
+ sessionFilePath: sessionFile,
713
+ turnCount: 50,
714
+ });
715
+ const elapsed = Date.now() - start;
716
+ assert.ok(result.success);
717
+ assert.ok(elapsed < 100, `Large session snapshot took ${elapsed}ms, expected < 100ms`);
718
+ // Snapshot should still be bounded
719
+ assert.ok(result.data.content.length <= 2000, `Snapshot from large session should still be bounded, got ${result.data.content.length}`);
720
+ });
721
+ it('should handle concurrent reads of session file', () => {
722
+ const sessionFile = join(SESSION_DIR, '95-6-session.md');
723
+ writeFileSync(sessionFile, '# Session\nContent\n');
724
+ // Multiple reads should not fail
725
+ const results = [];
726
+ for (let i = 0; i < 10; i++) {
727
+ results.push(writeContextSnapshot({
728
+ sessionDir: SESSION_DIR,
729
+ storyId: '95-6',
730
+ sessionFilePath: sessionFile,
731
+ turnCount: i + 1,
732
+ }));
733
+ }
734
+ for (const r of results) {
735
+ assert.ok(r.success, 'Concurrent snapshot writes should all succeed');
736
+ }
737
+ });
738
+ it('counter file should handle non-numeric content gracefully', () => {
739
+ writeFileSync(counterPath(), 'not-a-number\n');
740
+ const result = readTurnCounter(SESSION_DIR);
741
+ // Should either return 0 or handle gracefully
742
+ assert.ok(result.success, 'Non-numeric counter should not throw');
743
+ assert.strictEqual(typeof result.data, 'number');
744
+ });
745
+ });
746
+ });
747
+ //# sourceMappingURL=context-watch.test.js.map