prjct-cli 1.5.0 → 1.6.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 (49) hide show
  1. package/CHANGELOG.md +117 -1
  2. package/bin/prjct.ts +23 -14
  3. package/core/__tests__/agentic/command-executor.test.ts +19 -19
  4. package/core/__tests__/agentic/prompt-builder.test.ts +16 -16
  5. package/core/__tests__/utils/output.test.ts +7 -1
  6. package/core/agentic/command-executor.ts +18 -17
  7. package/core/agentic/orchestrator-executor.ts +126 -8
  8. package/core/agentic/prompt-builder.ts +58 -20
  9. package/core/agentic/template-executor.ts +2 -2
  10. package/core/ai-tools/registry.ts +18 -15
  11. package/core/cli/start.ts +18 -17
  12. package/core/commands/analysis.ts +6 -1
  13. package/core/commands/setup.ts +8 -8
  14. package/core/commands/uninstall.ts +11 -11
  15. package/core/context-tools/token-counter.ts +2 -0
  16. package/core/domain/agent-loader.ts +35 -1
  17. package/core/index.ts +12 -11
  18. package/core/infrastructure/agent-detector.ts +8 -8
  19. package/core/infrastructure/ai-provider.ts +49 -37
  20. package/core/infrastructure/command-installer.ts +18 -10
  21. package/core/infrastructure/path-manager.ts +4 -4
  22. package/core/infrastructure/setup.ts +124 -119
  23. package/core/infrastructure/update-checker.ts +14 -13
  24. package/core/integrations/linear/sync.ts +4 -4
  25. package/core/services/context-selector.ts +6 -3
  26. package/core/services/hierarchical-agent-resolver.ts +2 -0
  27. package/core/services/hooks-service.ts +78 -68
  28. package/core/services/sync-service.ts +115 -3
  29. package/core/session/compaction.ts +1 -1
  30. package/core/types/agentic.ts +22 -0
  31. package/core/types/agents.ts +5 -0
  32. package/core/types/index.ts +1 -0
  33. package/core/utils/fs-helpers.ts +14 -0
  34. package/core/utils/output.ts +15 -1
  35. package/core/utils/project-credentials.ts +8 -7
  36. package/dist/bin/prjct.mjs +1446 -750
  37. package/dist/core/infrastructure/command-installer.js +118 -87
  38. package/dist/core/infrastructure/setup.js +246 -210
  39. package/package.json +1 -1
  40. package/templates/config/skill-mappings.json +23 -60
  41. package/templates/subagents/domain/backend.md +1 -0
  42. package/templates/subagents/domain/database.md +1 -0
  43. package/templates/subagents/domain/devops.md +1 -0
  44. package/templates/subagents/domain/frontend.md +1 -0
  45. package/templates/subagents/domain/testing.md +1 -0
  46. package/templates/subagents/workflow/chief-architect.md +2 -1
  47. package/templates/subagents/workflow/prjct-planner.md +2 -1
  48. package/templates/subagents/workflow/prjct-shipper.md +1 -0
  49. package/templates/subagents/workflow/prjct-workflow.md +1 -0
package/CHANGELOG.md CHANGED
@@ -1,12 +1,128 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-02-06
4
+
5
+ ### Features
6
+
7
+ - super context for agents — skills.sh, proactive codebase context, effort/model (#121)
8
+
9
+
10
+ ## [1.6.0] - 2026-02-06
11
+
12
+ ### Features
13
+
14
+ - **Skills.sh auto-install**: During `prjct sync`, skills from skills.sh are automatically installed for generated agents. Real packages like `anthropics/skills/frontend-design`, `obra/superpowers/systematic-debugging`, and `obra/superpowers/test-driven-development` are mapped per agent domain.
15
+ - **Proactive codebase context**: The orchestrator now gathers real context before agent execution — git state, relevant files (scored by task relevance), code signatures from top files, and recently changed files. Agents start with a complete briefing instead of exploring first.
16
+ - **Effort/model metadata wiring**: Agent frontmatter `effort` and `model` fields are now extracted and injected into prompts, enabling per-agent reasoning depth control.
17
+
18
+ ### Improved
19
+
20
+ - **Skill loading warnings**: Missing skills now log visible warnings with the agent that needs them and a hint to run `prjct sync`
21
+ - **Skill content in prompts**: Increased skill content truncation from 1000 to 2000 characters for richer context
22
+ - **Skill mappings v3**: Updated `skill-mappings.json` from generic names to real installable skills.sh packages
23
+
24
+ ### Implementation Details
25
+
26
+ - `sync-service.ts`: New `autoInstallSkills()` method reads `skill-mappings.json`, checks if each skill is installed, and calls `skillInstaller.install()` for missing ones
27
+ - `orchestrator-executor.ts`: New `gatherRealContext()` calls `findRelevantFiles()`, `getRecentFiles()`, and `extractSignatures()` in parallel to build a proactive briefing
28
+ - `prompt-builder.ts`: New "CODEBASE CONTEXT" section with git state, relevant files table, code signatures, and recently changed files; plus effort/model per agent
29
+ - `agent-loader.ts`: New `extractFrontmatterMeta()` parses YAML frontmatter for effort/model fields
30
+ - `agentic.ts`: New `RealCodebaseContext` interface; `LoadedAgent` extended with `effort?` and `model?`
31
+
32
+ ### Test Plan
33
+
34
+ #### For QA
35
+ 1. Run `prjct sync` — verify skills auto-install (check `~/.claude/skills/`)
36
+ 2. Run `p. task "test"` — verify prompt includes git state, relevant files, signatures, effort/model
37
+ 3. Verify warnings for missing skills
38
+ 4. Build and all 416 tests pass
39
+
40
+ #### For Users
41
+ **What changed:** Agents receive proactive codebase context before starting work. Skills auto-install during sync.
42
+ **How to use:** No action needed — automatic during `prjct sync` and `p. task`.
43
+ **Breaking changes:** None
44
+
45
+ ## [1.5.2] - 2026-02-06
46
+
47
+ ### Improved
48
+
49
+ - **TTY detection for CLI spinners**: Spinner, step, and progress animations now detect non-TTY environments (CI/CD, Claude Code, piped output) and print a single static line instead of animating
50
+
51
+ ### Implementation Details
52
+
53
+ - Added `process.stdout.isTTY` guard to `spin()`, `step()`, and `progress()` in `core/utils/output.ts`
54
+ - Non-TTY environments get a single line with `\n` instead of `setInterval` with `\r` carriage returns
55
+ - Made `clear()` a no-op in non-TTY (the `\r` + spaces trick doesn't work outside terminals)
56
+ - Updated test for `stop()` to handle non-TTY behavior in test runner
57
+
58
+ ### Learnings
59
+
60
+ - `process.stdout.isTTY` is the reliable built-in way to detect interactive terminals in Node.js
61
+ - Test suites (bun test) also run as non-TTY, so test assertions need to account for both paths
62
+
63
+ ### Test Plan
64
+
65
+ #### For QA
66
+ 1. Run `prjct sync --yes` in an interactive terminal — spinner should animate normally
67
+ 2. Run `prjct sync --yes > out.txt` — output should show a single static line, no repeated frames
68
+ 3. Run inside Claude Code Bash tool — output should be clean, no spinner noise
69
+ 4. Verify `step()` and `progress()` behave the same way in both environments
70
+
71
+ #### For Users
72
+ **What changed:** CLI spinners no longer produce garbage output in non-interactive terminals
73
+ **How to use:** No action needed — automatic TTY detection
74
+ **Breaking changes:** None
75
+
76
+ ## [1.5.1] - 2026-02-06
77
+
78
+ ### Refactoring
79
+
80
+ - standardize on fs/promises across codebase (PRJ-93) (#118)
81
+
82
+
83
+ ## [1.5.1] - 2026-02-06
84
+
85
+ ### Changed
86
+
87
+ - **Standardize on fs/promises across codebase (PRJ-93)**: Replaced all synchronous `fs` operations (`existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`, etc.) with async `fs/promises` equivalents across 22 files
88
+
89
+ ### Implementation Details
90
+
91
+ - Created shared `fileExists()` utility in `core/utils/fs-helpers.ts` replacing `existsSync` with `fs.access`
92
+ - Converted all detection functions in `ai-provider.ts` to async with `Promise.all` for parallel checks
93
+ - Applied lazy initialization pattern in `CommandInstaller` to handle async `getActiveProvider()` in constructor
94
+ - Replaced `execSync` with `promisify(exec)` in `registry.ts` and `ai-provider.ts`
95
+ - Converted `setup.ts` (~60 sync ops), `hooks-service.ts` (~30 sync ops), and all command/CLI files
96
+ - Updated prompt-builder and command-executor tests to handle async `build()` and `signalStart/End()`
97
+ - Intentional exceptions: `version.ts` (module-level constants), `jsonl-helper.ts` (`createReadStream`), test files
98
+
99
+ ### Learnings
100
+
101
+ - Module-level constants (`VERSION`, `PACKAGE_ROOT`) cannot use async — sync reads at import time are a valid exception
102
+ - `createReadStream` is inherently sync (returns a stream) — the correct pattern for streaming reads
103
+ - Making a function async cascades to all callers — `ai-provider.ts` changes rippled to 10+ files
104
+ - Constructor methods can't be async — solved with lazy `ensureInit()` pattern in `CommandInstaller`
105
+
106
+ ### Test Plan
107
+
108
+ #### For QA
109
+ 1. Run `bun run build` — verify clean build with no errors
110
+ 2. Run `bun test` — verify all 416 tests pass
111
+ 3. Run `prjct sync` on a project — verify async fs operations work correctly
112
+ 4. Run `prjct start` — verify setup flow works with async file operations
113
+ 5. Verify `prjct linear list` works (uses converted linear/sync.ts)
114
+
115
+ #### For Users
116
+ **What changed:** Internal refactor — no user-facing API changes. All sync filesystem operations replaced with async equivalents for better performance.
117
+ **How to use:** No changes needed. All commands work identically.
118
+ **Breaking changes:** None
119
+
3
120
  ## [1.5.0] - 2026-02-06
4
121
 
5
122
  ### Features
6
123
 
7
124
  - add citation format for context sources in templates (PRJ-113) (#117)
8
125
 
9
-
10
126
  ## [1.4.2] - 2026-02-06
11
127
 
12
128
  ### Features
package/bin/prjct.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  * auto-install on first CLI use. This is the reliable path.
9
9
  */
10
10
 
11
- import fs from 'node:fs'
12
11
  import os from 'node:os'
13
12
  import path from 'node:path'
14
13
  import chalk from 'chalk'
@@ -16,20 +15,21 @@ import { detectAllProviders } from '../core/infrastructure/ai-provider'
16
15
  import configManager from '../core/infrastructure/config-manager'
17
16
  import editorsConfig from '../core/infrastructure/editors-config'
18
17
  import { DEFAULT_PORT, startServer } from '../core/server/server'
18
+ import { fileExists } from '../core/utils/fs-helpers'
19
19
  import { VERSION } from '../core/utils/version'
20
20
 
21
21
  /**
22
22
  * Check if routers are installed for detected providers
23
23
  * Returns true if at least one provider has its router installed
24
24
  */
25
- function checkRoutersInstalled(): boolean {
25
+ async function checkRoutersInstalled(): Promise<boolean> {
26
26
  const home = os.homedir()
27
- const detection = detectAllProviders()
27
+ const detection = await detectAllProviders()
28
28
 
29
29
  // Check Claude router
30
30
  if (detection.claude.installed) {
31
31
  const claudeRouter = path.join(home, '.claude', 'commands', 'p.md')
32
- if (!fs.existsSync(claudeRouter)) {
32
+ if (!(await fileExists(claudeRouter))) {
33
33
  return false
34
34
  }
35
35
  }
@@ -37,7 +37,7 @@ function checkRoutersInstalled(): boolean {
37
37
  // Check Gemini router
38
38
  if (detection.gemini.installed) {
39
39
  const geminiRouter = path.join(home, '.gemini', 'commands', 'p.toml')
40
- if (!fs.existsSync(geminiRouter)) {
40
+ if (!(await fileExists(geminiRouter))) {
41
41
  return false
42
42
  }
43
43
  }
@@ -218,15 +218,24 @@ if (args[0] === 'start' || args[0] === 'setup') {
218
218
  process.exitCode = 0
219
219
  } else if (args[0] === 'version' || args[0] === '-v' || args[0] === '--version') {
220
220
  // Show version with provider status
221
- const detection = detectAllProviders()
221
+ const detection = await detectAllProviders()
222
222
  const home = os.homedir()
223
223
  const cwd = process.cwd()
224
- const claudeConfigured = fs.existsSync(path.join(home, '.claude', 'commands', 'p.md'))
225
- const geminiConfigured = fs.existsSync(path.join(home, '.gemini', 'commands', 'p.toml'))
226
- const cursorDetected = fs.existsSync(path.join(cwd, '.cursor'))
227
- const cursorConfigured = fs.existsSync(path.join(cwd, '.cursor', 'rules', 'prjct.mdc'))
228
- const windsurfDetected = fs.existsSync(path.join(cwd, '.windsurf'))
229
- const windsurfConfigured = fs.existsSync(path.join(cwd, '.windsurf', 'rules', 'prjct.md'))
224
+ const [
225
+ claudeConfigured,
226
+ geminiConfigured,
227
+ cursorDetected,
228
+ cursorConfigured,
229
+ windsurfDetected,
230
+ windsurfConfigured,
231
+ ] = await Promise.all([
232
+ fileExists(path.join(home, '.claude', 'commands', 'p.md')),
233
+ fileExists(path.join(home, '.gemini', 'commands', 'p.toml')),
234
+ fileExists(path.join(cwd, '.cursor')),
235
+ fileExists(path.join(cwd, '.cursor', 'rules', 'prjct.mdc')),
236
+ fileExists(path.join(cwd, '.windsurf')),
237
+ fileExists(path.join(cwd, '.windsurf', 'rules', 'prjct.md')),
238
+ ])
230
239
 
231
240
  console.log(`
232
241
  ${chalk.cyan('p/')} prjct v${VERSION}
@@ -276,9 +285,9 @@ ${chalk.cyan('https://prjct.app')}
276
285
  } else {
277
286
  // Check if setup has been done
278
287
  const configPath = path.join(os.homedir(), '.prjct-cli', 'config', 'installed-editors.json')
279
- const routersInstalled = checkRoutersInstalled()
288
+ const routersInstalled = await checkRoutersInstalled()
280
289
 
281
- if (!fs.existsSync(configPath) || !routersInstalled) {
290
+ if (!(await fileExists(configPath)) || !routersInstalled) {
282
291
  // First time - prompt to run start
283
292
  console.log(`
284
293
  ${chalk.cyan.bold(' Welcome to prjct!')}
@@ -106,40 +106,40 @@ describe('CommandExecutor', () => {
106
106
  }
107
107
  })
108
108
 
109
- it('should create status file with command name', () => {
110
- signalStart('test-command')
109
+ it('should create status file with command name', async () => {
110
+ await signalStart('test-command')
111
111
 
112
112
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
113
113
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
114
114
  expect(content).toBe('/p:test-command')
115
115
  })
116
116
 
117
- it('should overwrite existing status file', () => {
118
- signalStart('first-command')
119
- signalStart('second-command')
117
+ it('should overwrite existing status file', async () => {
118
+ await signalStart('first-command')
119
+ await signalStart('second-command')
120
120
 
121
121
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
122
122
  expect(content).toBe('/p:second-command')
123
123
  })
124
124
 
125
- it('should handle filesystem errors gracefully', () => {
125
+ it('should handle filesystem errors gracefully', async () => {
126
126
  // This test verifies that errors are silently ignored
127
127
  // We can't easily simulate fs errors, but we can verify the function doesn't throw
128
- expect(() => signalStart('test-command')).not.toThrow()
128
+ await expect(signalStart('test-command')).resolves.toBeUndefined()
129
129
  })
130
130
  })
131
131
 
132
132
  describe('signalEnd', () => {
133
- it('should remove status file if it exists', () => {
133
+ it('should remove status file if it exists', async () => {
134
134
  // Create the file first
135
- signalStart('test-command')
135
+ await signalStart('test-command')
136
136
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
137
137
 
138
- signalEnd()
138
+ await signalEnd()
139
139
  expect(fs.existsSync(RUNNING_FILE)).toBe(false)
140
140
  })
141
141
 
142
- it('should not throw if file does not exist', () => {
142
+ it('should not throw if file does not exist', async () => {
143
143
  // Ensure file doesn't exist
144
144
  try {
145
145
  fs.unlinkSync(RUNNING_FILE)
@@ -147,7 +147,7 @@ describe('CommandExecutor', () => {
147
147
  // Ignore
148
148
  }
149
149
 
150
- expect(() => signalEnd()).not.toThrow()
150
+ await expect(signalEnd()).resolves.toBeUndefined()
151
151
  })
152
152
  })
153
153
 
@@ -158,20 +158,20 @@ describe('CommandExecutor', () => {
158
158
  executor = new CommandExecutor()
159
159
  })
160
160
 
161
- it('should have signalStart method that calls module function', () => {
162
- executor.signalStart('class-test')
161
+ it('should have signalStart method that calls module function', async () => {
162
+ await executor.signalStart('class-test')
163
163
 
164
164
  expect(fs.existsSync(RUNNING_FILE)).toBe(true)
165
165
  const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
166
166
  expect(content).toBe('/p:class-test')
167
167
 
168
168
  // Cleanup
169
- executor.signalEnd()
169
+ await executor.signalEnd()
170
170
  })
171
171
 
172
- it('should have signalEnd method that calls module function', () => {
173
- executor.signalStart('class-test')
174
- executor.signalEnd()
172
+ it('should have signalEnd method that calls module function', async () => {
173
+ await executor.signalStart('class-test')
174
+ await executor.signalEnd()
175
175
 
176
176
  expect(fs.existsSync(RUNNING_FILE)).toBe(false)
177
177
  })
@@ -336,7 +336,7 @@ describe('execute', () => {
336
336
  memorySystem.getRelevantMemories = mock(() => Promise.resolve([]))
337
337
 
338
338
  // Mock promptBuilder
339
- promptBuilder.build = mock(() => 'built prompt')
339
+ promptBuilder.build = mock(() => Promise.resolve('built prompt'))
340
340
  })
341
341
 
342
342
  afterEach(() => {
@@ -96,7 +96,7 @@ describe('PromptBuilder', () => {
96
96
  })
97
97
 
98
98
  describe('Context Filtering by Command Type', () => {
99
- it('should include patterns for code commands', () => {
99
+ it('should include patterns for code commands', async () => {
100
100
  const template = {
101
101
  frontmatter: { description: 'Build feature', name: 'p:build' },
102
102
  content: '## Flow\nBuild something',
@@ -105,13 +105,13 @@ describe('PromptBuilder', () => {
105
105
  const context = { projectPath: '/test', files: ['file1.js'] }
106
106
  const state = { analysis: 'Stack: Node.js, React' }
107
107
 
108
- const prompt = builder.build(template, context, state)
108
+ const prompt = await builder.build(template, context, state)
109
109
 
110
110
  expect(prompt).toContain('PATTERNS')
111
111
  expect(prompt).toContain('Node.js')
112
112
  })
113
113
 
114
- it('should NOT include patterns for non-code commands', () => {
114
+ it('should NOT include patterns for non-code commands', async () => {
115
115
  const template = {
116
116
  frontmatter: { description: 'Show current task', name: 'p:now' },
117
117
  content: '## Flow\nShow task',
@@ -120,14 +120,14 @@ describe('PromptBuilder', () => {
120
120
  const context = { projectPath: '/test', files: ['file1.js'] }
121
121
  const state = { analysis: 'Stack: Node.js, React' }
122
122
 
123
- const prompt = builder.build(template, context, state)
123
+ const prompt = await builder.build(template, context, state)
124
124
 
125
125
  expect(prompt).not.toContain('## PATTERNS')
126
126
  })
127
127
  })
128
128
 
129
129
  describe('Project Files Listing', () => {
130
- it('should list available files when context has files', () => {
130
+ it('should list available files when context has files', async () => {
131
131
  const template = {
132
132
  frontmatter: { description: 'Test command' },
133
133
  content: '## Flow\nDo something',
@@ -140,7 +140,7 @@ describe('PromptBuilder', () => {
140
140
 
141
141
  const state = {}
142
142
 
143
- const prompt = builder.build(template, context, state)
143
+ const prompt = await builder.build(template, context, state)
144
144
 
145
145
  expect(prompt).toContain('## FILES:')
146
146
  expect(prompt).toContain('3 available')
@@ -148,7 +148,7 @@ describe('PromptBuilder', () => {
148
148
  expect(prompt).toContain('Read')
149
149
  })
150
150
 
151
- it('should show project path when no files listed', () => {
151
+ it('should show project path when no files listed', async () => {
152
152
  const template = {
153
153
  frontmatter: { description: 'Test command' },
154
154
  content: '## Flow\nDo something',
@@ -157,7 +157,7 @@ describe('PromptBuilder', () => {
157
157
  const context = { projectPath: '/test/project' }
158
158
  const state = {}
159
159
 
160
- const prompt = builder.build(template, context, state)
160
+ const prompt = await builder.build(template, context, state)
161
161
 
162
162
  expect(prompt).toContain('## PROJECT:')
163
163
  expect(prompt).toContain('/test/project')
@@ -165,7 +165,7 @@ describe('PromptBuilder', () => {
165
165
  })
166
166
 
167
167
  describe('Build Complete Prompt', () => {
168
- it('should include all critical sections', () => {
168
+ it('should include all critical sections', async () => {
169
169
  const template = {
170
170
  frontmatter: {
171
171
  description: 'Test command',
@@ -182,7 +182,7 @@ describe('PromptBuilder', () => {
182
182
 
183
183
  const state = { now: '# NOW\n\n**Current task**' }
184
184
 
185
- const prompt = builder.build(template, context, state)
185
+ const prompt = await builder.build(template, context, state)
186
186
 
187
187
  expect(prompt).toContain('TASK:')
188
188
  expect(prompt).toContain('TOOLS:')
@@ -191,7 +191,7 @@ describe('PromptBuilder', () => {
191
191
  expect(prompt).toContain('## FILES:')
192
192
  })
193
193
 
194
- it('should be concise (under 2000 chars for simple prompt)', () => {
194
+ it('should be concise (under 2000 chars for simple prompt)', async () => {
195
195
  const template = {
196
196
  frontmatter: { description: 'Test', 'allowed-tools': ['Read'] },
197
197
  content: '## Flow\n1. Test',
@@ -200,14 +200,14 @@ describe('PromptBuilder', () => {
200
200
  const context = { projectPath: '/test', files: ['a.js'] }
201
201
  const state = {}
202
202
 
203
- const prompt = builder.build(template, context, state)
203
+ const prompt = await builder.build(template, context, state)
204
204
 
205
205
  expect(prompt.length).toBeLessThan(2000)
206
206
  })
207
207
  })
208
208
 
209
209
  describe('Plan Mode (Compressed)', () => {
210
- it('should include compact plan mode instructions', () => {
210
+ it('should include compact plan mode instructions', async () => {
211
211
  const template = {
212
212
  frontmatter: { description: 'Test' },
213
213
  content: '## Flow\nTest',
@@ -217,14 +217,14 @@ describe('PromptBuilder', () => {
217
217
  const state = {}
218
218
  const planInfo = { isPlanning: true, allowedTools: ['Read', 'Glob'] }
219
219
 
220
- const prompt = builder.build(template, context, state, null, null, null, null, planInfo)
220
+ const prompt = await builder.build(template, context, state, null, null, null, null, planInfo)
221
221
 
222
222
  expect(prompt).toContain('PLAN MODE')
223
223
  expect(prompt).toContain('Read-only')
224
224
  expect(prompt).toContain('Tools: Read, Glob')
225
225
  })
226
226
 
227
- it('should include approval required section', () => {
227
+ it('should include approval required section', async () => {
228
228
  const template = {
229
229
  frontmatter: { description: 'Test' },
230
230
  content: '## Flow\nTest',
@@ -234,7 +234,7 @@ describe('PromptBuilder', () => {
234
234
  const state = {}
235
235
  const planInfo = { requiresApproval: true }
236
236
 
237
- const prompt = builder.build(template, context, state, null, null, null, null, planInfo)
237
+ const prompt = await builder.build(template, context, state, null, null, null, null, planInfo)
238
238
 
239
239
  expect(prompt).toContain('APPROVAL REQUIRED')
240
240
  expect(prompt).toContain('confirmation')
@@ -124,7 +124,13 @@ describe('Output Module', () => {
124
124
  stdoutWriteSpy.mockClear()
125
125
  out.stop()
126
126
 
127
- expect(stdoutWriteSpy).toHaveBeenCalled()
127
+ // In TTY, stop() writes a clear sequence; in non-TTY, spinner doesn't
128
+ // use setInterval so stop() is a no-op (clear skips in non-TTY)
129
+ if (process.stdout.isTTY) {
130
+ expect(stdoutWriteSpy).toHaveBeenCalled()
131
+ } else {
132
+ expect(stdoutWriteSpy).not.toHaveBeenCalled()
133
+ }
128
134
  })
129
135
 
130
136
  it('should be safe to call multiple times', () => {
@@ -6,7 +6,7 @@
6
6
  * @version 3.4
7
7
  */
8
8
 
9
- import fs from 'node:fs'
9
+ import fs from 'node:fs/promises'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import type {
@@ -18,6 +18,7 @@ import type {
18
18
  SimpleExecutionResult,
19
19
  } from '../types'
20
20
  import { agentStream } from '../utils/agent-stream'
21
+ import { fileExists } from '../utils/fs-helpers'
21
22
  import { printSubtaskProgress, type SubtaskDisplay } from '../utils/subtask-table'
22
23
  import chainOfThought from './chain-of-thought'
23
24
  import contextBuilder from './context-builder'
@@ -40,13 +41,13 @@ const RUNNING_FILE = path.join(os.homedir(), '.prjct-cli', '.running')
40
41
  /**
41
42
  * Signal that a command is running (for status line)
42
43
  */
43
- export function signalStart(commandName: string): void {
44
+ export async function signalStart(commandName: string): Promise<void> {
44
45
  try {
45
46
  const dir = path.dirname(RUNNING_FILE)
46
- if (!fs.existsSync(dir)) {
47
- fs.mkdirSync(dir, { recursive: true })
47
+ if (!(await fileExists(dir))) {
48
+ await fs.mkdir(dir, { recursive: true })
48
49
  }
49
- fs.writeFileSync(RUNNING_FILE, `/p:${commandName}`)
50
+ await fs.writeFile(RUNNING_FILE, `/p:${commandName}`)
50
51
  } catch (_error) {
51
52
  // Silently ignore - status line is optional
52
53
  }
@@ -55,10 +56,10 @@ export function signalStart(commandName: string): void {
55
56
  /**
56
57
  * Signal that command has finished (for status line)
57
58
  */
58
- export function signalEnd(): void {
59
+ export async function signalEnd(): Promise<void> {
59
60
  try {
60
- if (fs.existsSync(RUNNING_FILE)) {
61
- fs.unlinkSync(RUNNING_FILE)
61
+ if (await fileExists(RUNNING_FILE)) {
62
+ await fs.unlink(RUNNING_FILE)
62
63
  }
63
64
  } catch (_error) {
64
65
  // Silently ignore - status line is optional
@@ -73,15 +74,15 @@ export class CommandExecutor {
73
74
  /**
74
75
  * Signal that a command is running (for status line)
75
76
  */
76
- signalStart(commandName: string): void {
77
- signalStart(commandName)
77
+ async signalStart(commandName: string): Promise<void> {
78
+ await signalStart(commandName)
78
79
  }
79
80
 
80
81
  /**
81
82
  * Signal that command has finished (for status line)
82
83
  */
83
- signalEnd(): void {
84
- signalEnd()
84
+ async signalEnd(): Promise<void> {
85
+ await signalEnd()
85
86
  }
86
87
 
87
88
  /**
@@ -93,7 +94,7 @@ export class CommandExecutor {
93
94
  projectPath: string
94
95
  ): Promise<ExecutionResult> {
95
96
  // Signal start for status line
96
- this.signalStart(commandName)
97
+ await this.signalStart(commandName)
97
98
 
98
99
  // Context for loop detection
99
100
  const loopContext = (params.task as string) || (params.description as string) || ''
@@ -101,7 +102,7 @@ export class CommandExecutor {
101
102
  // Check if we're in a loop BEFORE attempting
102
103
  if (loopDetector.shouldEscalate(commandName, loopContext)) {
103
104
  const escalation = loopDetector.getEscalationInfo(commandName, loopContext)
104
- this.signalEnd()
105
+ await this.signalEnd()
105
106
  return {
106
107
  success: false,
107
108
  error: escalation?.message,
@@ -264,7 +265,7 @@ export class CommandExecutor {
264
265
  }
265
266
  // Agent is null - Claude assigns via Task tool using agent-routing.md
266
267
  // Pass orchestratorContext for domain/agent/subtask injection
267
- const prompt = promptBuilder.build(
268
+ const prompt = await promptBuilder.build(
268
269
  template,
269
270
  context,
270
271
  state,
@@ -286,7 +287,7 @@ export class CommandExecutor {
286
287
  loopDetector.recordSuccess(commandName, loopContext)
287
288
 
288
289
  // Signal end for status line
289
- this.signalEnd()
290
+ await this.signalEnd()
290
291
 
291
292
  return {
292
293
  success: true,
@@ -373,7 +374,7 @@ export class CommandExecutor {
373
374
  }
374
375
  } catch (error) {
375
376
  // Signal end for status line
376
- this.signalEnd()
377
+ await this.signalEnd()
377
378
 
378
379
  // Record failed attempt for loop detection
379
380
  const attemptInfo = loopDetector.recordAttempt(commandName, loopContext, {