@scanton/phase2s 0.13.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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/bin/phase2s.d.ts +3 -0
- package/dist/bin/phase2s.d.ts.map +1 -0
- package/dist/bin/phase2s.js +7 -0
- package/dist/bin/phase2s.js.map +1 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.d.ts.map +1 -0
- package/dist/src/cli/index.js +499 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/core/agent.d.ts +63 -0
- package/dist/src/core/agent.d.ts.map +1 -0
- package/dist/src/core/agent.js +178 -0
- package/dist/src/core/agent.js.map +1 -0
- package/dist/src/core/config.d.ts +52 -0
- package/dist/src/core/config.d.ts.map +1 -0
- package/dist/src/core/config.js +103 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/conversation.d.ts +44 -0
- package/dist/src/core/conversation.d.ts.map +1 -0
- package/dist/src/core/conversation.js +128 -0
- package/dist/src/core/conversation.js.map +1 -0
- package/dist/src/core/memory.d.ts +24 -0
- package/dist/src/core/memory.d.ts.map +1 -0
- package/dist/src/core/memory.js +65 -0
- package/dist/src/core/memory.js.map +1 -0
- package/dist/src/mcp/server.d.ts +73 -0
- package/dist/src/mcp/server.d.ts.map +1 -0
- package/dist/src/mcp/server.js +215 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/providers/codex.d.ts +28 -0
- package/dist/src/providers/codex.d.ts.map +1 -0
- package/dist/src/providers/codex.js +162 -0
- package/dist/src/providers/codex.js.map +1 -0
- package/dist/src/providers/index.d.ts +6 -0
- package/dist/src/providers/index.d.ts.map +1 -0
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/index.js.map +1 -0
- package/dist/src/providers/openai.d.ts +36 -0
- package/dist/src/providers/openai.d.ts.map +1 -0
- package/dist/src/providers/openai.js +117 -0
- package/dist/src/providers/openai.js.map +1 -0
- package/dist/src/providers/types.d.ts +34 -0
- package/dist/src/providers/types.d.ts.map +1 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/providers/types.js.map +1 -0
- package/dist/src/skills/index.d.ts +3 -0
- package/dist/src/skills/index.d.ts.map +1 -0
- package/dist/src/skills/index.js +2 -0
- package/dist/src/skills/index.js.map +1 -0
- package/dist/src/skills/loader.d.ts +23 -0
- package/dist/src/skills/loader.d.ts.map +1 -0
- package/dist/src/skills/loader.js +136 -0
- package/dist/src/skills/loader.js.map +1 -0
- package/dist/src/skills/types.d.ts +14 -0
- package/dist/src/skills/types.d.ts.map +1 -0
- package/dist/src/skills/types.js +2 -0
- package/dist/src/skills/types.js.map +1 -0
- package/dist/src/tools/file-read.d.ts +3 -0
- package/dist/src/tools/file-read.d.ts.map +1 -0
- package/dist/src/tools/file-read.js +50 -0
- package/dist/src/tools/file-read.js.map +1 -0
- package/dist/src/tools/file-write.d.ts +3 -0
- package/dist/src/tools/file-write.d.ts.map +1 -0
- package/dist/src/tools/file-write.js +90 -0
- package/dist/src/tools/file-write.js.map +1 -0
- package/dist/src/tools/glob-tool.d.ts +3 -0
- package/dist/src/tools/glob-tool.d.ts.map +1 -0
- package/dist/src/tools/glob-tool.js +38 -0
- package/dist/src/tools/glob-tool.js.map +1 -0
- package/dist/src/tools/grep-tool.d.ts +3 -0
- package/dist/src/tools/grep-tool.d.ts.map +1 -0
- package/dist/src/tools/grep-tool.js +59 -0
- package/dist/src/tools/grep-tool.js.map +1 -0
- package/dist/src/tools/index.d.ts +5 -0
- package/dist/src/tools/index.d.ts.map +1 -0
- package/dist/src/tools/index.js +17 -0
- package/dist/src/tools/index.js.map +1 -0
- package/dist/src/tools/registry.d.ts +13 -0
- package/dist/src/tools/registry.d.ts.map +1 -0
- package/dist/src/tools/registry.js +50 -0
- package/dist/src/tools/registry.js.map +1 -0
- package/dist/src/tools/sandbox.d.ts +29 -0
- package/dist/src/tools/sandbox.d.ts.map +1 -0
- package/dist/src/tools/sandbox.js +84 -0
- package/dist/src/tools/sandbox.js.map +1 -0
- package/dist/src/tools/shell.d.ts +10 -0
- package/dist/src/tools/shell.d.ts.map +1 -0
- package/dist/src/tools/shell.js +108 -0
- package/dist/src/tools/shell.js.map +1 -0
- package/dist/src/tools/types.d.ts +25 -0
- package/dist/src/tools/types.d.ts.map +1 -0
- package/dist/src/tools/types.js +54 -0
- package/dist/src/tools/types.js.map +1 -0
- package/dist/src/utils/logger.d.ts +10 -0
- package/dist/src/utils/logger.d.ts.map +1 -0
- package/dist/src/utils/logger.js +25 -0
- package/dist/src/utils/logger.js.map +1 -0
- package/dist/src/utils/prompt.d.ts +3 -0
- package/dist/src/utils/prompt.d.ts.map +1 -0
- package/dist/src/utils/prompt.js +26 -0
- package/dist/src/utils/prompt.js.map +1 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 scanton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Phase2S
|
|
2
|
+
|
|
3
|
+
Phase2S is a personal AI coding assistant you run in your terminal. You type questions about your code, ask it to review files, debug problems, or implement a feature — and it answers using your existing ChatGPT subscription or OpenAI API key.
|
|
4
|
+
|
|
5
|
+
Think of it as a slash-command layer on top of AI. Instead of typing "please review this file for security issues and flag each problem with a severity level", you type `/review src/core/auth.ts` and get a structured, consistent answer every time.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
you > /review src/core/agent.ts
|
|
9
|
+
assistant > Reviewing src/core/agent.ts...
|
|
10
|
+
|
|
11
|
+
CRIT: The `maxTurns` check runs after tool execution, not before.
|
|
12
|
+
An LLM that loops tool calls can exceed the limit by one turn.
|
|
13
|
+
|
|
14
|
+
WARN: `getConversation()` returns the live object, not a copy.
|
|
15
|
+
Callers that mutate it will corrupt the conversation state.
|
|
16
|
+
|
|
17
|
+
NIT: Inline comment on line 47 is stale — describes old batch behavior.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick install
|
|
23
|
+
|
|
24
|
+
Requires [Node.js](https://nodejs.org) >= 20.
|
|
25
|
+
|
|
26
|
+
**Option A: ChatGPT Plus or Pro subscription (recommended)**
|
|
27
|
+
|
|
28
|
+
No API key, no per-token billing. All 29 skills work.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @openai/codex @scanton/phase2s
|
|
32
|
+
codex auth
|
|
33
|
+
phase2s
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Option B: OpenAI API key**
|
|
37
|
+
|
|
38
|
+
Unlocks token-by-token streaming and model-per-skill routing.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g @scanton/phase2s
|
|
42
|
+
export OPENAI_API_KEY=sk-your-key-here
|
|
43
|
+
export PHASE2S_PROVIDER=openai-api
|
|
44
|
+
phase2s
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
Once you're at the `you >` prompt:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
you > /review src/core/agent.ts — code review with CRIT/WARN/NIT tagging
|
|
55
|
+
you > /diff — review all uncommitted changes
|
|
56
|
+
you > /satori add rate limiting — implement + test + retry until green
|
|
57
|
+
you > /health — code quality score (tests, types, lint)
|
|
58
|
+
you > /remember — save a project convention to memory
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
One-shot mode (no REPL):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
phase2s run "explain what src/core/agent.ts does"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Resume your last session:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
phase2s --resume
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## What's included
|
|
76
|
+
|
|
77
|
+
29 built-in skills across 6 categories. A few highlights:
|
|
78
|
+
|
|
79
|
+
- `/satori` — implement a task, run `npm test`, retry on failure (up to 3 times). Stops when tests are green, not when the model thinks it's done.
|
|
80
|
+
- `/consensus-plan` — planner + architect + critic passes before producing a plan. Catches the errors that only show up in implementation.
|
|
81
|
+
- `/deep-specify` — Socratic interview before writing code. Saves a spec with Intent, Boundaries, and Success criteria.
|
|
82
|
+
- `/debug` — reproduce, isolate, fix, and verify a bug end-to-end.
|
|
83
|
+
- `/remember` — save project conventions to persistent memory. Injected into every future session automatically.
|
|
84
|
+
- `/skill` — create a new `/command` from inside Phase2S. Three questions, no YAML editing.
|
|
85
|
+
- `/land-and-deploy` — push, open a PR, merge it, wait for CI, confirm the land. Picks up where `/ship` leaves off.
|
|
86
|
+
|
|
87
|
+
List everything:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
phase2s skills
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Docs
|
|
96
|
+
|
|
97
|
+
- [Getting started](docs/getting-started.md) — full setup walkthrough, first session, first skill call
|
|
98
|
+
- [Skills reference](docs/skills.md) — all 29 skills with examples and arguments
|
|
99
|
+
- [Workflows](docs/workflows.md) — real development sessions: feature, debug, review, weekly rhythm
|
|
100
|
+
- [Memory and persistence](docs/memory.md) — session resume, `/remember`, what Phase2S writes to disk
|
|
101
|
+
- [Writing custom skills](docs/writing-skills.md) — SKILL.md format, frontmatter fields, examples
|
|
102
|
+
- [Advanced](docs/advanced.md) — streaming, tool loop, model routing (requires API key)
|
|
103
|
+
- [Claude Code integration](docs/claude-code.md) — MCP server setup, cross-model adversarial review
|
|
104
|
+
- [Configuration](docs/configuration.md) — `.phase2s.yaml` reference, environment variables
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
- [x] Codex CLI provider (uses ChatGPT subscription, no API key required)
|
|
111
|
+
- [x] 29 built-in skills across 6 categories
|
|
112
|
+
- [x] SKILL.md compatibility with `~/.codex/skills/`
|
|
113
|
+
- [x] Smart skill argument parsing (file paths vs. context strings)
|
|
114
|
+
- [x] File sandbox: tools reject paths outside the project directory, including symlink escapes
|
|
115
|
+
- [x] 208 tests covering all tools, core modules, and agent integration (`npm test`)
|
|
116
|
+
- [x] CI: runs `npm test` on every push and PR (GitHub Actions, Node.js 22)
|
|
117
|
+
- [x] Direct OpenAI API provider with live tool calling
|
|
118
|
+
- [x] Streaming output — responses stream token-by-token
|
|
119
|
+
- [x] `npm install -g @scanton/phase2s`
|
|
120
|
+
- [x] Session persistence — auto-save after each turn, `--resume` to continue
|
|
121
|
+
- [x] Model-per-skill routing — `fast_model` / `smart_model` tiers in `.phase2s.yaml`
|
|
122
|
+
- [x] Satori persistent execution — retry loop with shell verification, context snapshots, attempt logs
|
|
123
|
+
- [x] Consensus planning — planner + architect + critic passes
|
|
124
|
+
- [x] Claude Code MCP integration — all skills available as Claude Code tools via `phase2s mcp`
|
|
125
|
+
- [x] `/adversarial` skill — cross-model adversarial review with structured output
|
|
126
|
+
- [x] Persistent memory — `/remember` saves learnings to `.phase2s/memory/learnings.jsonl`
|
|
127
|
+
- [x] `/skill` meta-skill — create new skills from inside Phase2S
|
|
128
|
+
- [x] Session file security — session files written with `mode: 0o600` (owner-only)
|
|
129
|
+
- [x] `/land-and-deploy` skill — push, PR, CI wait, merge, deploy confirmation via `gh` CLI
|
|
130
|
+
- [ ] Real Codex streaming (JSONL stdout parsing)
|
|
131
|
+
- [ ] npm publish
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"phase2s.d.ts","sourceRoot":"","sources":["../../bin/phase2s.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"phase2s.js","sourceRoot":"","sources":["../../bin/phase2s.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE3C,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/cli/index.ts"],"names":[],"mappings":"AA8CA,wBAAsB,IAAI,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEvE"}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { access, constants, readdir } from "node:fs/promises";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { loadConfig } from "../core/config.js";
|
|
10
|
+
import { Agent } from "../core/agent.js";
|
|
11
|
+
import { Conversation } from "../core/conversation.js";
|
|
12
|
+
import { loadLearnings, formatLearningsForPrompt } from "../core/memory.js";
|
|
13
|
+
import { loadAllSkills } from "../skills/index.js";
|
|
14
|
+
import { log } from "../utils/logger.js";
|
|
15
|
+
const VERSION = "0.13.0";
|
|
16
|
+
/** Directory for session auto-saves. */
|
|
17
|
+
const SESSION_DIR = join(process.cwd(), ".phase2s", "sessions");
|
|
18
|
+
/** Path for today's session file. */
|
|
19
|
+
function todaySessionPath() {
|
|
20
|
+
const d = new Date();
|
|
21
|
+
const datePart = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
22
|
+
return join(SESSION_DIR, `${datePart}.json`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Find the most recent session file in SESSION_DIR.
|
|
26
|
+
* Returns null if no session files exist.
|
|
27
|
+
*/
|
|
28
|
+
async function findLatestSession() {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(SESSION_DIR);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Only match YYYY-MM-DD.json filenames so stray .json files don't become sessions.
|
|
37
|
+
const sessions = entries
|
|
38
|
+
.filter((e) => /^\d{4}-\d{2}-\d{2}\.json$/.test(e))
|
|
39
|
+
.sort()
|
|
40
|
+
.reverse();
|
|
41
|
+
return sessions.length > 0 ? join(SESSION_DIR, sessions[0]) : null;
|
|
42
|
+
}
|
|
43
|
+
export async function main(argv = process.argv) {
|
|
44
|
+
const program = new Command();
|
|
45
|
+
program
|
|
46
|
+
.name("phase2s")
|
|
47
|
+
.description("AI programming harness with multi-model support")
|
|
48
|
+
.version(VERSION)
|
|
49
|
+
.option("-p, --provider <provider>", "LLM provider (codex-cli | openai-api)")
|
|
50
|
+
.option("-m, --model <model>", "Model to use")
|
|
51
|
+
.option("--system <prompt>", "Custom system prompt")
|
|
52
|
+
.option("--resume", "Resume the most recent session");
|
|
53
|
+
// Default command: interactive REPL
|
|
54
|
+
program
|
|
55
|
+
.command("chat", { isDefault: true })
|
|
56
|
+
.description("Start an interactive chat session")
|
|
57
|
+
.action(async () => {
|
|
58
|
+
const opts = program.opts();
|
|
59
|
+
const config = await loadConfig({
|
|
60
|
+
provider: opts.provider,
|
|
61
|
+
model: opts.model,
|
|
62
|
+
systemPrompt: opts.system,
|
|
63
|
+
});
|
|
64
|
+
await interactiveMode(config, { resume: !!opts.resume });
|
|
65
|
+
});
|
|
66
|
+
// One-shot mode
|
|
67
|
+
program
|
|
68
|
+
.command("run <prompt>")
|
|
69
|
+
.description("Run a single prompt and exit")
|
|
70
|
+
.action(async (prompt) => {
|
|
71
|
+
const opts = program.opts();
|
|
72
|
+
const config = await loadConfig({
|
|
73
|
+
provider: opts.provider,
|
|
74
|
+
model: opts.model,
|
|
75
|
+
systemPrompt: opts.system,
|
|
76
|
+
});
|
|
77
|
+
await oneShotMode(config, prompt);
|
|
78
|
+
});
|
|
79
|
+
// MCP server — exposes all Phase2S skills as Claude Code tools
|
|
80
|
+
program
|
|
81
|
+
.command("mcp")
|
|
82
|
+
.description("Start Phase2S as an MCP server for Claude Code integration")
|
|
83
|
+
.action(async () => {
|
|
84
|
+
const { runMCPServer } = await import("../mcp/server.js");
|
|
85
|
+
await runMCPServer(process.cwd());
|
|
86
|
+
});
|
|
87
|
+
// List available skills
|
|
88
|
+
program
|
|
89
|
+
.command("skills")
|
|
90
|
+
.description("List available skills")
|
|
91
|
+
.action(async () => {
|
|
92
|
+
const skills = await loadAllSkills();
|
|
93
|
+
if (skills.length === 0) {
|
|
94
|
+
log.info("No skills found. Add skills to .phase2s/skills/ or ~/.phase2s/skills/");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log(chalk.bold("\nAvailable skills:\n"));
|
|
98
|
+
for (const skill of skills) {
|
|
99
|
+
console.log(` ${chalk.cyan("/" + skill.name)} — ${skill.description || "(no description)"}`);
|
|
100
|
+
}
|
|
101
|
+
console.log();
|
|
102
|
+
});
|
|
103
|
+
await program.parseAsync(argv);
|
|
104
|
+
}
|
|
105
|
+
const UNDERSPEC_WORD_THRESHOLD = 15;
|
|
106
|
+
function isUnderspecified(prompt) {
|
|
107
|
+
const words = prompt.trim().split(/\s+/).filter(Boolean);
|
|
108
|
+
const hasFilePath = /[./]/.test(prompt);
|
|
109
|
+
return words.length < UNDERSPEC_WORD_THRESHOLD && !hasFilePath;
|
|
110
|
+
}
|
|
111
|
+
function makeSlug(prompt) {
|
|
112
|
+
return prompt
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
115
|
+
.trim()
|
|
116
|
+
.replace(/\s+/g, "-")
|
|
117
|
+
.slice(0, 40);
|
|
118
|
+
}
|
|
119
|
+
async function writeContextSnapshot(prompt, config) {
|
|
120
|
+
const dir = resolve(".phase2s", "context");
|
|
121
|
+
await mkdir(dir, { recursive: true });
|
|
122
|
+
const slug = makeSlug(prompt);
|
|
123
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16);
|
|
124
|
+
const filename = `${ts}-${slug}.md`;
|
|
125
|
+
const filePath = resolve(dir, filename);
|
|
126
|
+
let gitLog = "";
|
|
127
|
+
let gitDiff = "";
|
|
128
|
+
let branch = "unknown";
|
|
129
|
+
try {
|
|
130
|
+
branch = execSync("git branch --show-current", { encoding: "utf-8" }).trim();
|
|
131
|
+
gitLog = execSync("git log --oneline -5", { encoding: "utf-8" }).trim();
|
|
132
|
+
gitDiff = execSync("git diff --stat HEAD", { encoding: "utf-8" }).trim();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Not a git repo or git not available
|
|
136
|
+
}
|
|
137
|
+
const content = [
|
|
138
|
+
`# Context Snapshot: ${slug}`,
|
|
139
|
+
`Date: ${new Date().toISOString()}`,
|
|
140
|
+
`Branch: ${branch}`,
|
|
141
|
+
`Task: ${prompt.slice(0, 200)}`,
|
|
142
|
+
"",
|
|
143
|
+
"## Codebase Context",
|
|
144
|
+
gitLog || "(no git log)",
|
|
145
|
+
"",
|
|
146
|
+
gitDiff || "(no uncommitted changes)",
|
|
147
|
+
"",
|
|
148
|
+
"## Success Criteria",
|
|
149
|
+
`Passes: ${config.verifyCommand ?? "npm test"}`,
|
|
150
|
+
"",
|
|
151
|
+
"## Unknowns",
|
|
152
|
+
"[to be filled by agent during first pass]",
|
|
153
|
+
].join("\n");
|
|
154
|
+
await writeFile(filePath, content, "utf-8");
|
|
155
|
+
log.dim(`Context snapshot: .phase2s/context/${filename}`);
|
|
156
|
+
}
|
|
157
|
+
async function writeSatoriLog(slug, startedAt, result, config, allAttempts) {
|
|
158
|
+
const dir = resolve(".phase2s", "satori");
|
|
159
|
+
await mkdir(dir, { recursive: true });
|
|
160
|
+
const filePath = resolve(dir, `${slug}.json`);
|
|
161
|
+
const log_data = {
|
|
162
|
+
taskSlug: slug,
|
|
163
|
+
startedAt,
|
|
164
|
+
completedAt: new Date().toISOString(),
|
|
165
|
+
maxRetries: allAttempts.length,
|
|
166
|
+
verifyCommand: config.verifyCommand ?? "npm test",
|
|
167
|
+
attempts: allAttempts.map((a) => ({
|
|
168
|
+
attempt: a.attempt,
|
|
169
|
+
passed: a.passed,
|
|
170
|
+
exitCode: a.passed ? 0 : 1,
|
|
171
|
+
failureLines: a.verifyOutput
|
|
172
|
+
.split("\n")
|
|
173
|
+
.filter((l) => /fail|error|assert/i.test(l))
|
|
174
|
+
.slice(0, 5),
|
|
175
|
+
})),
|
|
176
|
+
finalStatus: result.passed ? "passed" : "failed",
|
|
177
|
+
};
|
|
178
|
+
await writeFile(filePath, JSON.stringify(log_data, null, 2), "utf-8");
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Interactive REPL.
|
|
182
|
+
*
|
|
183
|
+
* Uses a manual event-queue pattern (rl.on('line')) rather than the
|
|
184
|
+
* readline async iterator. The async iterator has a known issue where
|
|
185
|
+
* it terminates if the event loop drains while awaiting between turns —
|
|
186
|
+
* which is exactly what happens while the LLM is streaming.
|
|
187
|
+
*/
|
|
188
|
+
async function interactiveMode(config, opts = {}) {
|
|
189
|
+
if (!(await checkCodexBinary(config)))
|
|
190
|
+
process.exit(1);
|
|
191
|
+
if (!checkOpenAIKey(config))
|
|
192
|
+
process.exit(1);
|
|
193
|
+
// --resume: load most recent session
|
|
194
|
+
let resumedConversation;
|
|
195
|
+
if (opts.resume) {
|
|
196
|
+
const sessionPath = await findLatestSession();
|
|
197
|
+
if (sessionPath) {
|
|
198
|
+
try {
|
|
199
|
+
resumedConversation = await Conversation.load(sessionPath);
|
|
200
|
+
console.log(chalk.dim(`Resuming session from ${sessionPath} (${resumedConversation.length} messages)\n`));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
console.log(chalk.yellow("Warning: Could not load previous session. Starting fresh.\n"));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.log(chalk.yellow("No previous session found. Starting fresh.\n"));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
console.log(chalk.bold(`\nPhase2S v${VERSION}`));
|
|
211
|
+
console.log(chalk.dim("Type your message and press Enter. Type /quit to exit.\n"));
|
|
212
|
+
// Load persistent memory learnings from .phase2s/memory/learnings.jsonl
|
|
213
|
+
const learningsList = await loadLearnings(process.cwd());
|
|
214
|
+
const learningsStr = formatLearningsForPrompt(learningsList);
|
|
215
|
+
if (learningsList.length > 0) {
|
|
216
|
+
log.dim(`Learnings: ${learningsList.length} ${learningsList.length === 1 ? "entry" : "entries"} from .phase2s/memory/`);
|
|
217
|
+
}
|
|
218
|
+
const agent = new Agent({ config, conversation: resumedConversation, learnings: learningsStr });
|
|
219
|
+
const skills = await loadAllSkills();
|
|
220
|
+
// Session auto-save path — today's file
|
|
221
|
+
const sessionPath = todaySessionPath();
|
|
222
|
+
// Ensure stdin is open and stays open for the full session
|
|
223
|
+
process.stdin.resume();
|
|
224
|
+
process.stdin.setEncoding("utf8");
|
|
225
|
+
const rl = createInterface({
|
|
226
|
+
input: process.stdin,
|
|
227
|
+
output: process.stdout,
|
|
228
|
+
terminal: true,
|
|
229
|
+
});
|
|
230
|
+
// Manual async queue: lines go in via rl.on('line'), come out via nextLine()
|
|
231
|
+
const lineQueue = [];
|
|
232
|
+
let pendingResolve = null;
|
|
233
|
+
let isOpen = true;
|
|
234
|
+
rl.on("line", (line) => {
|
|
235
|
+
if (pendingResolve) {
|
|
236
|
+
const resolve = pendingResolve;
|
|
237
|
+
pendingResolve = null;
|
|
238
|
+
resolve(line);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
lineQueue.push(line);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
rl.on("close", () => {
|
|
245
|
+
isOpen = false;
|
|
246
|
+
if (pendingResolve) {
|
|
247
|
+
pendingResolve(null);
|
|
248
|
+
pendingResolve = null;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
rl.on("SIGINT", () => {
|
|
252
|
+
process.stdout.write("\n");
|
|
253
|
+
// Synchronous save before exit — async saveSession() can't complete after process.exit().
|
|
254
|
+
try {
|
|
255
|
+
mkdirSync(resolve(sessionPath, ".."), { recursive: true });
|
|
256
|
+
writeFileSync(sessionPath, JSON.stringify(agent.getConversation().getMessages(), null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Best-effort — don't block exit on save failure
|
|
260
|
+
}
|
|
261
|
+
log.info("Goodbye!");
|
|
262
|
+
rl.close();
|
|
263
|
+
process.exit(0);
|
|
264
|
+
});
|
|
265
|
+
/** Wait for the next line of input. Returns null if stdin closes. */
|
|
266
|
+
const nextLine = () => {
|
|
267
|
+
if (lineQueue.length > 0)
|
|
268
|
+
return Promise.resolve(lineQueue.shift());
|
|
269
|
+
if (!isOpen)
|
|
270
|
+
return Promise.resolve(null);
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
pendingResolve = resolve;
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
/** Save the current conversation to today's session file (best-effort). */
|
|
276
|
+
const saveSession = async () => {
|
|
277
|
+
try {
|
|
278
|
+
// mode 0o600: session files may contain code, file paths, or secrets —
|
|
279
|
+
// restrict to owner-only to prevent world-readable exposure on multi-user systems.
|
|
280
|
+
await agent.getConversation().save(sessionPath, 0o600);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Best-effort — session save failures don't interrupt the user
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const writePrompt = () => process.stdout.write(chalk.green("you > "));
|
|
287
|
+
// Main REPL loop
|
|
288
|
+
while (true) {
|
|
289
|
+
writePrompt();
|
|
290
|
+
const line = await nextLine();
|
|
291
|
+
if (line === null)
|
|
292
|
+
break; // stdin closed cleanly
|
|
293
|
+
const trimmed = line.trim();
|
|
294
|
+
if (!trimmed)
|
|
295
|
+
continue;
|
|
296
|
+
if (trimmed === "/quit" || trimmed === "/exit") {
|
|
297
|
+
log.info("Goodbye!");
|
|
298
|
+
rl.close();
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
if (trimmed === "/help") {
|
|
302
|
+
printHelp(skills);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// Skill invocation — batch mode (no streaming, keeps the "Running..." indicator)
|
|
306
|
+
if (trimmed.startsWith("/")) {
|
|
307
|
+
const skillName = trimmed.slice(1).split(" ")[0];
|
|
308
|
+
const skill = skills.find((s) => s.name === skillName);
|
|
309
|
+
if (skill) {
|
|
310
|
+
const args = trimmed.slice(1 + skillName.length).trim();
|
|
311
|
+
const expanded = skill.promptTemplate + buildSkillContext(args);
|
|
312
|
+
// Underspecification gate
|
|
313
|
+
if (config.requireSpecification && !args.startsWith("force:")) {
|
|
314
|
+
const checkPrompt = args || trimmed;
|
|
315
|
+
if (isUnderspecified(checkPrompt)) {
|
|
316
|
+
console.log(chalk.yellow("⚠ This prompt seems underspecified. Add more detail, or prefix with 'force:' to proceed."));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const safeArgs = args.startsWith("force:") ? args.slice("force:".length).trim() : args;
|
|
321
|
+
const finalExpanded = skill.promptTemplate + buildSkillContext(safeArgs);
|
|
322
|
+
process.stdout.write(chalk.dim(`Running /${skill.name}${safeArgs ? ` on: ${safeArgs}` : ""}...\n`));
|
|
323
|
+
// Satori mode: skill declares retries > 0
|
|
324
|
+
if (skill.retries && skill.retries > 0) {
|
|
325
|
+
const slug = makeSlug(expanded.slice(0, 100));
|
|
326
|
+
const startedAt = new Date().toISOString();
|
|
327
|
+
const attempts = [];
|
|
328
|
+
try {
|
|
329
|
+
const response = await agent.run(finalExpanded, {
|
|
330
|
+
modelOverride: skill.model,
|
|
331
|
+
maxRetries: skill.retries,
|
|
332
|
+
verifyCommand: config.verifyCommand,
|
|
333
|
+
preRun: () => writeContextSnapshot(expanded, config),
|
|
334
|
+
postRun: async (result) => {
|
|
335
|
+
attempts.push(result);
|
|
336
|
+
await writeSatoriLog(slug, startedAt, result, config, attempts);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
console.log(chalk.bold("\nassistant > ") + response + "\n");
|
|
340
|
+
await saveSession();
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// Normal skill run
|
|
348
|
+
try {
|
|
349
|
+
const response = await agent.run(finalExpanded, { modelOverride: skill.model });
|
|
350
|
+
console.log(chalk.bold("\nassistant > ") + response + "\n");
|
|
351
|
+
await saveSession();
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Normal message — stream deltas as they arrive
|
|
361
|
+
process.stdout.write(chalk.bold("\nassistant > "));
|
|
362
|
+
try {
|
|
363
|
+
await agent.run(trimmed, { onDelta: (chunk) => process.stdout.write(chunk) });
|
|
364
|
+
process.stdout.write("\n\n");
|
|
365
|
+
await saveSession();
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
process.stdout.write("\n");
|
|
369
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Check that the configured codex binary is executable before starting.
|
|
375
|
+
* Prints a clear, actionable error message if not found — rather than letting
|
|
376
|
+
* the user hit a cryptic ENOENT from spawn() mid-session.
|
|
377
|
+
*/
|
|
378
|
+
async function checkCodexBinary(config) {
|
|
379
|
+
if (config.provider !== "codex-cli")
|
|
380
|
+
return true;
|
|
381
|
+
const codexPath = config.codexPath;
|
|
382
|
+
// If it looks like an absolute/relative path, check directly
|
|
383
|
+
if (codexPath.startsWith("/") || codexPath.startsWith(".")) {
|
|
384
|
+
try {
|
|
385
|
+
await access(resolve(codexPath), constants.X_OK);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// fall through to error
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
// Search PATH entries
|
|
394
|
+
const pathDirs = (process.env.PATH ?? "").split(":");
|
|
395
|
+
for (const dir of pathDirs) {
|
|
396
|
+
try {
|
|
397
|
+
await access(resolve(dir, codexPath), constants.X_OK);
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// not in this dir
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
console.error(chalk.red(`\n✗ "${codexPath}" not found or not executable.\n`) +
|
|
406
|
+
chalk.dim(" Install Codex CLI: npm install -g @openai/codex\n" +
|
|
407
|
+
" Or switch provider: PHASE2S_PROVIDER=openai-api phase2s\n" +
|
|
408
|
+
" Or set path: PHASE2S_CODEX_PATH=/path/to/codex phase2s\n"));
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Pre-flight check for the openai-api provider: verify API key is set
|
|
413
|
+
* before opening the REPL, so the user gets a clear error at startup
|
|
414
|
+
* rather than mid-session.
|
|
415
|
+
*/
|
|
416
|
+
function checkOpenAIKey(config) {
|
|
417
|
+
if (config.provider !== "openai-api")
|
|
418
|
+
return true;
|
|
419
|
+
if (config.apiKey)
|
|
420
|
+
return true;
|
|
421
|
+
console.error(chalk.red("\n✗ OpenAI API key not found.\n") +
|
|
422
|
+
chalk.dim(" Set it: export OPENAI_API_KEY=sk-...\n" +
|
|
423
|
+
" Or add: apiKey: sk-... in .phase2s.yaml\n"));
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
async function oneShotMode(config, prompt) {
|
|
427
|
+
if (!(await checkCodexBinary(config)))
|
|
428
|
+
process.exit(1);
|
|
429
|
+
if (!checkOpenAIKey(config))
|
|
430
|
+
process.exit(1);
|
|
431
|
+
// Load persistent memory learnings from .phase2s/memory/learnings.jsonl
|
|
432
|
+
const learningsList = await loadLearnings(process.cwd());
|
|
433
|
+
const learningsStr = formatLearningsForPrompt(learningsList);
|
|
434
|
+
if (learningsList.length > 0) {
|
|
435
|
+
log.dim(`Learnings: ${learningsList.length} ${learningsList.length === 1 ? "entry" : "entries"} from .phase2s/memory/`);
|
|
436
|
+
}
|
|
437
|
+
const agent = new Agent({ config, learnings: learningsStr });
|
|
438
|
+
let hasOutput = false;
|
|
439
|
+
try {
|
|
440
|
+
const result = await agent.run(prompt, { onDelta: (chunk) => { process.stdout.write(chunk); hasOutput = true; } });
|
|
441
|
+
if (!hasOutput) {
|
|
442
|
+
// Fallback: tool-only path with no final text (rare in practice)
|
|
443
|
+
process.stdout.write(result);
|
|
444
|
+
}
|
|
445
|
+
process.stdout.write("\n");
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Build context section to append to a skill's prompt template based on
|
|
454
|
+
* the arguments the user typed after the skill name.
|
|
455
|
+
*
|
|
456
|
+
* `/review` → no context appended
|
|
457
|
+
* `/review src/core/agent.ts` → "Focus on this file: src/core/agent.ts"
|
|
458
|
+
* `/review src/core/agent.ts src/cli/index.ts` → "Focus on these files: ..."
|
|
459
|
+
* `/investigate why does the REPL exit` → "Additional context: why does..."
|
|
460
|
+
*/
|
|
461
|
+
function buildSkillContext(args) {
|
|
462
|
+
if (!args)
|
|
463
|
+
return "";
|
|
464
|
+
// Split on whitespace and check if all tokens look like file paths
|
|
465
|
+
// (contain a / or . suggesting a path/extension, no special chars)
|
|
466
|
+
const tokens = args.split(/\s+/).filter(Boolean);
|
|
467
|
+
const looksLikeFilePath = (s) => /^[./~]/.test(s) || /\.\w{1,6}$/.test(s);
|
|
468
|
+
const filePaths = tokens.filter(looksLikeFilePath);
|
|
469
|
+
const rest = tokens.filter((t) => !looksLikeFilePath(t)).join(" ");
|
|
470
|
+
const parts = [];
|
|
471
|
+
if (filePaths.length === 1) {
|
|
472
|
+
parts.push(`\n\nFocus on this file: ${filePaths[0]}`);
|
|
473
|
+
}
|
|
474
|
+
else if (filePaths.length > 1) {
|
|
475
|
+
parts.push(`\n\nFocus on these files:\n${filePaths.map((f) => ` - ${f}`).join("\n")}`);
|
|
476
|
+
}
|
|
477
|
+
if (rest) {
|
|
478
|
+
parts.push(`\n\nAdditional context: ${rest}`);
|
|
479
|
+
}
|
|
480
|
+
// If no file paths detected, treat whole thing as context
|
|
481
|
+
if (filePaths.length === 0) {
|
|
482
|
+
return `\n\nAdditional context: ${args}`;
|
|
483
|
+
}
|
|
484
|
+
return parts.join("");
|
|
485
|
+
}
|
|
486
|
+
function printHelp(skills) {
|
|
487
|
+
console.log(chalk.bold("\nPhase2S Commands:\n"));
|
|
488
|
+
console.log(" /help — Show this help");
|
|
489
|
+
console.log(" /quit — Exit the session");
|
|
490
|
+
console.log(" /exit — Exit the session");
|
|
491
|
+
if (skills.length > 0) {
|
|
492
|
+
console.log(chalk.bold("\nSkills:"));
|
|
493
|
+
for (const skill of skills) {
|
|
494
|
+
console.log(` /${skill.name} — ${skill.description || "(no description)"}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
console.log();
|
|
498
|
+
}
|
|
499
|
+
//# sourceMappingURL=index.js.map
|