pi-sage 0.2.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.
@@ -0,0 +1,202 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ReasoningLevel, ToolProfile } from "./types.js";
5
+
6
+ export type SettingsScope = "project" | "global";
7
+
8
+ export interface ToolPolicySettings {
9
+ profile: ToolProfile;
10
+ customAllowedTools?: string[];
11
+ maxToolCalls?: number;
12
+ maxFilesRead?: number;
13
+ maxBytesPerFile?: number;
14
+ maxTotalBytesRead?: number;
15
+ sensitivePathDenylist?: string[];
16
+ }
17
+
18
+ export interface SageSettings {
19
+ enabled: boolean;
20
+ autonomousEnabled: boolean;
21
+ explicitRequestAlwaysAllowed: boolean;
22
+ invocationScope: "interactive-primary-only";
23
+ model: string;
24
+ reasoningLevel: ReasoningLevel;
25
+ timeoutMs: number;
26
+ maxCallsPerTurn: number;
27
+ maxCallsPerSession: number;
28
+ cooldownTurnsBetweenAutoCalls: number;
29
+ maxQuestionChars: number;
30
+ maxContextChars: number;
31
+ maxEstimatedCostPerSession?: number;
32
+ toolPolicy: ToolPolicySettings;
33
+ }
34
+
35
+ export interface LoadedSettings {
36
+ settings: SageSettings;
37
+ source: "project" | "global" | "default";
38
+ projectPath: string;
39
+ globalPath: string;
40
+ }
41
+
42
+ const DEFAULT_DENYLIST = [".env", ".env.", ".pem", ".key", "id_rsa", "credentials", "secrets"];
43
+
44
+ export const DEFAULT_SAGE_SETTINGS: SageSettings = {
45
+ enabled: true,
46
+ autonomousEnabled: true,
47
+ explicitRequestAlwaysAllowed: true,
48
+ invocationScope: "interactive-primary-only",
49
+ model: "anthropic/claude-opus-4-6",
50
+ reasoningLevel: "high",
51
+ timeoutMs: 120000,
52
+ maxCallsPerTurn: 1,
53
+ maxCallsPerSession: 6,
54
+ cooldownTurnsBetweenAutoCalls: 1,
55
+ maxQuestionChars: 6000,
56
+ maxContextChars: 20000,
57
+ toolPolicy: {
58
+ profile: "read-only-lite",
59
+ maxToolCalls: 10,
60
+ maxFilesRead: 8,
61
+ maxBytesPerFile: 200 * 1024,
62
+ maxTotalBytesRead: 1024 * 1024,
63
+ sensitivePathDenylist: DEFAULT_DENYLIST
64
+ }
65
+ };
66
+
67
+ export function getProjectSettingsPath(cwd: string): string {
68
+ return join(cwd, ".pi", "sage-settings.json");
69
+ }
70
+
71
+ export function getGlobalSettingsPath(): string {
72
+ return join(homedir(), ".pi", "agent", "sage-settings.json");
73
+ }
74
+
75
+ function normalizeStringArray(value: unknown): string[] | undefined {
76
+ if (!Array.isArray(value)) return undefined;
77
+ return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
78
+ }
79
+
80
+ function normalizeNumber(value: unknown): number | undefined {
81
+ if (typeof value !== "number" || Number.isFinite(value) === false) return undefined;
82
+ return value;
83
+ }
84
+
85
+ export function mergeSettings(override: Partial<SageSettings> | undefined): SageSettings {
86
+ const merged: SageSettings = {
87
+ ...DEFAULT_SAGE_SETTINGS,
88
+ ...override,
89
+ toolPolicy: {
90
+ ...DEFAULT_SAGE_SETTINGS.toolPolicy,
91
+ ...(override?.toolPolicy ?? {})
92
+ }
93
+ };
94
+
95
+ merged.timeoutMs = Math.max(1000, Math.floor(merged.timeoutMs));
96
+ merged.maxCallsPerTurn = Math.max(1, Math.floor(merged.maxCallsPerTurn));
97
+ merged.maxCallsPerSession = Math.max(1, Math.floor(merged.maxCallsPerSession));
98
+ merged.cooldownTurnsBetweenAutoCalls = Math.max(0, Math.floor(merged.cooldownTurnsBetweenAutoCalls));
99
+ merged.maxQuestionChars = Math.max(256, Math.floor(merged.maxQuestionChars));
100
+ merged.maxContextChars = Math.max(512, Math.floor(merged.maxContextChars));
101
+
102
+ const policy = merged.toolPolicy;
103
+ policy.maxToolCalls = Math.max(1, Math.floor(policy.maxToolCalls ?? 10));
104
+ policy.maxFilesRead = Math.max(1, Math.floor(policy.maxFilesRead ?? 8));
105
+ policy.maxBytesPerFile = Math.max(1024, Math.floor(policy.maxBytesPerFile ?? 200 * 1024));
106
+ policy.maxTotalBytesRead = Math.max(policy.maxBytesPerFile, Math.floor(policy.maxTotalBytesRead ?? 1024 * 1024));
107
+ policy.sensitivePathDenylist = normalizeStringArray(policy.sensitivePathDenylist) ?? [...DEFAULT_DENYLIST];
108
+
109
+ if (merged.maxEstimatedCostPerSession !== undefined && merged.maxEstimatedCostPerSession < 0) {
110
+ delete merged.maxEstimatedCostPerSession;
111
+ }
112
+
113
+ return merged;
114
+ }
115
+
116
+ function parseSettingsRaw(content: string): Partial<SageSettings> | undefined {
117
+ try {
118
+ const parsed = JSON.parse(content) as Record<string, unknown>;
119
+ const toolPolicyRaw = (parsed.toolPolicy ?? {}) as Record<string, unknown>;
120
+
121
+ const profile =
122
+ toolPolicyRaw.profile === "none" ||
123
+ toolPolicyRaw.profile === "read-only-lite" ||
124
+ toolPolicyRaw.profile === "custom-read-only" ||
125
+ toolPolicyRaw.profile === "git-review-readonly"
126
+ ? toolPolicyRaw.profile
127
+ : undefined;
128
+
129
+ const toolPolicy: ToolPolicySettings | undefined = profile
130
+ ? {
131
+ profile,
132
+ customAllowedTools: normalizeStringArray(toolPolicyRaw.customAllowedTools),
133
+ maxToolCalls: normalizeNumber(toolPolicyRaw.maxToolCalls),
134
+ maxFilesRead: normalizeNumber(toolPolicyRaw.maxFilesRead),
135
+ maxBytesPerFile: normalizeNumber(toolPolicyRaw.maxBytesPerFile),
136
+ maxTotalBytesRead: normalizeNumber(toolPolicyRaw.maxTotalBytesRead),
137
+ sensitivePathDenylist: normalizeStringArray(toolPolicyRaw.sensitivePathDenylist)
138
+ }
139
+ : undefined;
140
+
141
+ const raw: Partial<SageSettings> = {
142
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : undefined,
143
+ autonomousEnabled: typeof parsed.autonomousEnabled === "boolean" ? parsed.autonomousEnabled : undefined,
144
+ explicitRequestAlwaysAllowed:
145
+ typeof parsed.explicitRequestAlwaysAllowed === "boolean" ? parsed.explicitRequestAlwaysAllowed : undefined,
146
+ invocationScope:
147
+ parsed.invocationScope === "interactive-primary-only" ? "interactive-primary-only" : undefined,
148
+ model: typeof parsed.model === "string" ? parsed.model : undefined,
149
+ reasoningLevel:
150
+ parsed.reasoningLevel === "minimal" ||
151
+ parsed.reasoningLevel === "low" ||
152
+ parsed.reasoningLevel === "medium" ||
153
+ parsed.reasoningLevel === "high" ||
154
+ parsed.reasoningLevel === "xhigh"
155
+ ? parsed.reasoningLevel
156
+ : undefined,
157
+ timeoutMs: normalizeNumber(parsed.timeoutMs),
158
+ maxCallsPerTurn: normalizeNumber(parsed.maxCallsPerTurn),
159
+ maxCallsPerSession: normalizeNumber(parsed.maxCallsPerSession),
160
+ cooldownTurnsBetweenAutoCalls: normalizeNumber(parsed.cooldownTurnsBetweenAutoCalls),
161
+ maxQuestionChars: normalizeNumber(parsed.maxQuestionChars),
162
+ maxContextChars: normalizeNumber(parsed.maxContextChars),
163
+ maxEstimatedCostPerSession: normalizeNumber(parsed.maxEstimatedCostPerSession),
164
+ toolPolicy
165
+ };
166
+
167
+ return raw;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
173
+ function readSettingsFile(path: string): Partial<SageSettings> | undefined {
174
+ if (existsSync(path) === false) return undefined;
175
+ try {
176
+ return parseSettingsRaw(readFileSync(path, "utf8"));
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ export function loadSettings(cwd: string): LoadedSettings {
183
+ const projectPath = getProjectSettingsPath(cwd);
184
+ const globalPath = getGlobalSettingsPath();
185
+
186
+ const project = readSettingsFile(projectPath);
187
+ if (project) return { settings: mergeSettings(project), source: "project", projectPath, globalPath };
188
+
189
+ const global = readSettingsFile(globalPath);
190
+ if (global) return { settings: mergeSettings(global), source: "global", projectPath, globalPath };
191
+
192
+ return { settings: mergeSettings(undefined), source: "default", projectPath, globalPath };
193
+ }
194
+
195
+ export function saveSettings(path: string, settings: SageSettings): void {
196
+ mkdirSync(dirname(path), { recursive: true });
197
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
198
+ }
199
+
200
+ export function getSettingsPathForScope(cwd: string, scope: SettingsScope): string {
201
+ return scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath();
202
+ }
@@ -0,0 +1,195 @@
1
+ import type { BlockCode, ToolUsage, ToolProfile } from "./types.js";
2
+ import type { ToolPolicySettings } from "./settings.js";
3
+
4
+ export const READ_ONLY_LITE_TOOLS = ["ls", "glob", "grep", "read"] as const;
5
+ export const GIT_REVIEW_READONLY_TOOLS = ["ls", "glob", "grep", "read", "bash"] as const;
6
+
7
+ const CLI_TOOL_NAME_MAP: Record<string, string> = {
8
+ glob: "find"
9
+ };
10
+
11
+ const READ_ONLY_CUSTOM_ALLOWLIST = new Set(["ls", "glob", "find", "grep", "read"]);
12
+ const HARD_DISALLOWED_TOOLS = new Set(["edit", "write", "bash", "sage_consult"]);
13
+
14
+ const BASH_META_CHARS = /[;&|><`$\n\r]/;
15
+ const SAFE_TOKEN = /^[A-Za-z0-9_./:@+=,~%-]+$/;
16
+
17
+ const GIT_SUBCOMMAND_ALLOWLIST = new Set(["status", "diff", "show", "log", "blame", "rev-parse", "branch"]);
18
+
19
+ export interface ResolvedToolPolicy {
20
+ profile: ToolPolicySettings["profile"];
21
+ allowedTools: string[];
22
+ cliTools: string[];
23
+ maxToolCalls: number;
24
+ maxFilesRead: number;
25
+ maxBytesPerFile: number;
26
+ maxTotalBytesRead: number;
27
+ sensitivePathDenylist: string[];
28
+ }
29
+
30
+ export function getDisallowedCustomTools(customAllowedTools: string[] | undefined): string[] {
31
+ if (!customAllowedTools) return [];
32
+ return customAllowedTools.filter((tool) => HARD_DISALLOWED_TOOLS.has(tool.trim()));
33
+ }
34
+
35
+ export function resolveToolPolicy(settings: ToolPolicySettings): ResolvedToolPolicy {
36
+ const requestedProfile = settings.profile;
37
+
38
+ if (requestedProfile === "none") {
39
+ return {
40
+ profile: "none",
41
+ allowedTools: [],
42
+ cliTools: [],
43
+ maxToolCalls: settings.maxToolCalls ?? 10,
44
+ maxFilesRead: settings.maxFilesRead ?? 8,
45
+ maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
46
+ maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
47
+ sensitivePathDenylist: settings.sensitivePathDenylist ?? []
48
+ };
49
+ }
50
+
51
+ if (requestedProfile === "read-only-lite") {
52
+ const allowedTools = [...READ_ONLY_LITE_TOOLS];
53
+ return {
54
+ profile: "read-only-lite",
55
+ allowedTools,
56
+ cliTools: toCliTools(allowedTools),
57
+ maxToolCalls: settings.maxToolCalls ?? 10,
58
+ maxFilesRead: settings.maxFilesRead ?? 8,
59
+ maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
60
+ maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
61
+ sensitivePathDenylist: settings.sensitivePathDenylist ?? []
62
+ };
63
+ }
64
+
65
+ if (requestedProfile === "git-review-readonly") {
66
+ const allowedTools = [...GIT_REVIEW_READONLY_TOOLS];
67
+ return {
68
+ profile: "git-review-readonly",
69
+ allowedTools,
70
+ cliTools: toCliTools(allowedTools),
71
+ maxToolCalls: settings.maxToolCalls ?? 20,
72
+ maxFilesRead: settings.maxFilesRead ?? 20,
73
+ maxBytesPerFile: settings.maxBytesPerFile ?? 300 * 1024,
74
+ maxTotalBytesRead: settings.maxTotalBytesRead ?? 2 * 1024 * 1024,
75
+ sensitivePathDenylist: settings.sensitivePathDenylist ?? []
76
+ };
77
+ }
78
+
79
+ const requested = settings.customAllowedTools ?? [];
80
+ const filtered = requested
81
+ .map((tool) => tool.trim())
82
+ .filter(Boolean)
83
+ .filter((tool) => HARD_DISALLOWED_TOOLS.has(tool) === false)
84
+ .filter((tool) => READ_ONLY_CUSTOM_ALLOWLIST.has(tool));
85
+
86
+ const deduped = [...new Set(filtered)];
87
+
88
+ return {
89
+ profile: "custom-read-only",
90
+ allowedTools: deduped,
91
+ cliTools: toCliTools(deduped),
92
+ maxToolCalls: settings.maxToolCalls ?? 10,
93
+ maxFilesRead: settings.maxFilesRead ?? 8,
94
+ maxBytesPerFile: settings.maxBytesPerFile ?? 200 * 1024,
95
+ maxTotalBytesRead: settings.maxTotalBytesRead ?? 1024 * 1024,
96
+ sensitivePathDenylist: settings.sensitivePathDenylist ?? []
97
+ };
98
+ }
99
+
100
+ function toCliTools(allowedTools: string[]): string[] {
101
+ const mapped = allowedTools.map((tool) => CLI_TOOL_NAME_MAP[tool] ?? tool);
102
+ return [...new Set(mapped)];
103
+ }
104
+
105
+ export function checkVolumeCaps(
106
+ usage: ToolUsage,
107
+ policy: Pick<ResolvedToolPolicy, "maxToolCalls" | "maxFilesRead" | "maxBytesPerFile" | "maxTotalBytesRead">
108
+ ): { ok: boolean; blockCode?: BlockCode; reason: string } {
109
+ if (usage.callsUsed > policy.maxToolCalls) {
110
+ return { ok: false, blockCode: "volume-cap", reason: "Exceeded max tool calls" };
111
+ }
112
+ if (usage.filesRead > policy.maxFilesRead) {
113
+ return { ok: false, blockCode: "volume-cap", reason: "Exceeded max files read" };
114
+ }
115
+ if (usage.bytesRead > policy.maxTotalBytesRead) {
116
+ return { ok: false, blockCode: "volume-cap", reason: "Exceeded max total bytes read" };
117
+ }
118
+ return { ok: true, reason: "within caps" };
119
+ }
120
+
121
+ export function isPathAllowed(
122
+ targetPath: string,
123
+ workspaceRoots: string[],
124
+ denylist: string[]
125
+ ): { ok: boolean; blockCode?: BlockCode; reason: string } {
126
+ const normalizedPath = normalizePath(targetPath);
127
+ const normalizedRoots = workspaceRoots.map(normalizePath);
128
+ const isInWorkspace = normalizedRoots.some((root) => normalizedPath.startsWith(root));
129
+
130
+ if (isInWorkspace === false) {
131
+ return { ok: false, blockCode: "path-denied", reason: "Path is outside workspace roots" };
132
+ }
133
+
134
+ const match = denylist.find((entry) => normalizedPath.includes(entry.toLowerCase()));
135
+ if (match) {
136
+ return { ok: false, blockCode: "path-denied", reason: `Path matches denylist entry: ${match}` };
137
+ }
138
+
139
+ return { ok: true, reason: "allowed" };
140
+ }
141
+
142
+ export function validateBashCommandForProfile(
143
+ profile: ToolProfile,
144
+ command: string
145
+ ): { ok: boolean; blockCode?: BlockCode; reason: string } {
146
+ if (profile !== "git-review-readonly") {
147
+ return { ok: false, blockCode: "tool-disallowed", reason: "bash is only allowed in git-review-readonly profile" };
148
+ }
149
+
150
+ const trimmed = command.trim();
151
+ if (!trimmed) {
152
+ return { ok: false, blockCode: "tool-disallowed", reason: "Empty bash command" };
153
+ }
154
+
155
+ if (BASH_META_CHARS.test(trimmed)) {
156
+ return { ok: false, blockCode: "tool-disallowed", reason: "Shell control characters are not allowed" };
157
+ }
158
+
159
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
160
+ if (tokens.length < 2 || tokens[0] !== "git") {
161
+ return { ok: false, blockCode: "tool-disallowed", reason: "Only git read-only commands are allowed" };
162
+ }
163
+
164
+ const subcommand = tokens[1] ?? "";
165
+ if (!GIT_SUBCOMMAND_ALLOWLIST.has(subcommand)) {
166
+ return { ok: false, blockCode: "tool-disallowed", reason: `Git subcommand not allowed: ${subcommand}` };
167
+ }
168
+
169
+ for (const token of tokens.slice(2)) {
170
+ if (!SAFE_TOKEN.test(token)) {
171
+ return { ok: false, blockCode: "tool-disallowed", reason: `Unsafe token in command: ${token}` };
172
+ }
173
+ }
174
+
175
+ if (subcommand === "branch") {
176
+ const args = tokens.slice(2);
177
+ if (args.length !== 1 || args[0] !== "--show-current") {
178
+ return { ok: false, blockCode: "tool-disallowed", reason: "Only 'git branch --show-current' is allowed" };
179
+ }
180
+ }
181
+
182
+ if (subcommand === "rev-parse") {
183
+ const args = tokens.slice(2);
184
+ const allowedArgs = new Set(["--abbrev-ref", "HEAD"]);
185
+ if (args.some((arg) => !allowedArgs.has(arg))) {
186
+ return { ok: false, blockCode: "tool-disallowed", reason: "Unsupported git rev-parse arguments" };
187
+ }
188
+ }
189
+
190
+ return { ok: true, reason: "allowed" };
191
+ }
192
+
193
+ function normalizePath(pathValue: string): string {
194
+ return pathValue.replace(/\\/g, "/").toLowerCase();
195
+ }
@@ -0,0 +1,108 @@
1
+ export type SageMode = "autonomous" | "user-requested";
2
+
3
+ export type ReasoningLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
4
+
5
+ export type Objective = "debug" | "design" | "review" | "refactor" | "general";
6
+
7
+ export type Urgency = "low" | "medium" | "high";
8
+
9
+ export type BlockCode =
10
+ | "ineligible-caller"
11
+ | "non-interactive"
12
+ | "ci-mode"
13
+ | "rpc-role"
14
+ | "subagent"
15
+ | "unknown-context"
16
+ | "tool-disallowed"
17
+ | "path-denied"
18
+ | "volume-cap"
19
+ | "disabled"
20
+ | "model-unavailable"
21
+ | "timeout"
22
+ | "cost-cap"
23
+ | "soft-limit"
24
+ | "execution-error";
25
+
26
+ export interface CallerContext {
27
+ session: { interactive: boolean };
28
+ agent: {
29
+ role: string;
30
+ isSubagent: boolean;
31
+ isRpcOrchestrated: boolean;
32
+ };
33
+ runtime: { mode: "interactive" | "ci" | "non-interactive" | "rpc" };
34
+ }
35
+
36
+ export interface CallerDecision {
37
+ ok: boolean;
38
+ reason: string;
39
+ blockCode?: BlockCode;
40
+ }
41
+
42
+ export type ToolProfile = "none" | "read-only-lite" | "custom-read-only" | "git-review-readonly";
43
+
44
+ export interface ToolPolicyCaps {
45
+ maxToolCalls: number;
46
+ maxFilesRead: number;
47
+ maxBytesPerFile: number;
48
+ maxTotalBytesRead: number;
49
+ }
50
+
51
+ export interface ToolUsage {
52
+ profile: ToolProfile;
53
+ callsUsed: number;
54
+ filesRead: number;
55
+ bytesRead: number;
56
+ }
57
+
58
+ export interface UsageBreakdown {
59
+ input: number;
60
+ output: number;
61
+ cacheRead: number;
62
+ cacheWrite: number;
63
+ totalTokens: number;
64
+ costTotal?: number;
65
+ }
66
+
67
+ export interface SagePolicyMetadata {
68
+ mode: SageMode;
69
+ allowedByContext: boolean;
70
+ contextReason?: string;
71
+ blockCode?: BlockCode;
72
+ allowedByBudget: boolean;
73
+ budgetReason?: string;
74
+ }
75
+
76
+ export interface SageConsultDetails {
77
+ model: string;
78
+ reasoningLevel: ReasoningLevel;
79
+ latencyMs: number;
80
+ stopReason: string;
81
+ usage?: UsageBreakdown;
82
+ toolUsage?: ToolUsage;
83
+ policy: SagePolicyMetadata;
84
+ }
85
+
86
+ export interface SageToolResult {
87
+ content: Array<{ type: "text"; text: string }>;
88
+ details: SageConsultDetails;
89
+ isError?: boolean;
90
+ }
91
+
92
+ export interface SageConsultInput {
93
+ question: string;
94
+ context?: string;
95
+ files?: string[];
96
+ evidence?: string[];
97
+ objective?: Objective;
98
+ urgency?: Urgency;
99
+ force?: boolean;
100
+ }
101
+
102
+ export interface SageBudgetState {
103
+ currentTurn: number;
104
+ callsThisTurn: number;
105
+ sessionCalls: number;
106
+ lastAutoTurn: number | undefined;
107
+ sessionCostTotal: number;
108
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,87 @@
1
+ # Sage Project North Star (agents.md)
2
+
3
+ This file is the entry point for contributors and autonomous agents working in this repository.
4
+
5
+ ## 1) Mission
6
+
7
+ Build Sage as a **safe, advisory, interactive-only reasoning assistant** for Pi.
8
+
9
+ Sage should improve decision quality for complex tasks while preserving strict safety and control boundaries.
10
+
11
+ ## 2) Product Invariants (Do Not Violate)
12
+
13
+ 1. Sage invocation is restricted to **interactive top-level primary** sessions.
14
+ 2. RPC-orchestrated roles (`supervisor`, `worker`, `reviewer`, `merger`) cannot invoke Sage.
15
+ 3. Sage is **single-shot** per call.
16
+ 4. Sage cannot recursively invoke Sage.
17
+ 5. Sage is **advisory-only** (analysis/recommendations, not implementation execution).
18
+ 6. Unknown caller context is denied by default.
19
+
20
+ ## 3) Canonical Project Documents
21
+
22
+ Read in this order:
23
+
24
+ 1. `docs/SAGE_SPEC.md` — locked product/architecture contract (implementation baseline)
25
+ 2. `docs/coding-standards.md` — implementation standards and review checklist
26
+ 3. `docs/testing-standards.md` — required quality gates and test methodology
27
+ 4. `docs/interactive-e2e-harness.md` — interactive-only E2E strategy (tmux-driven)
28
+
29
+ If docs conflict, resolve by updating the spec and standards together in the same change.
30
+
31
+ ## 4) Engineering Priorities (in order)
32
+
33
+ 1. **Safety & policy correctness**
34
+ 2. **Deterministic behavior and clear blocking semantics**
35
+ 3. **Observability and debuggability**
36
+ 4. **Developer ergonomics**
37
+ 5. **Performance/cost optimization**
38
+
39
+ ## 5) Development Workflow Expectations
40
+
41
+ For every meaningful code change:
42
+
43
+ 1. Update implementation
44
+ 2. Run quality gates (including lint)
45
+ 3. Run tests appropriate to change scope
46
+ 4. Update docs when behavior/contract changes
47
+
48
+ Minimum continuous test gates:
49
+ - lint
50
+ - typecheck
51
+ - unit
52
+ - integration
53
+ - e2e (for behavior-impacting changes)
54
+
55
+ Testing runner policy:
56
+ - Use Node’s built-in test runner (`node:test` via `node --test`) for all test layers.
57
+ - Vitest is not permitted in this repository.
58
+
59
+ ## 6) Definition of Ready for Implementation Tasks
60
+
61
+ Before coding, ensure:
62
+ - scope is clear and mapped to spec section(s)
63
+ - acceptance criteria are explicit
64
+ - safety/policy impact is identified
65
+ - required tests are listed
66
+
67
+ ## 7) Definition of Done for Implementation Tasks
68
+
69
+ A task is complete when:
70
+ - behavior matches `docs/SAGE_SPEC.md`
71
+ - coding standards are satisfied
72
+ - testing standards pass (including lint)
73
+ - docs/spec are updated for contract changes
74
+ - no regression in caller gating, tool policy, or recursion protections
75
+
76
+ ## 8) Change Management Guidance
77
+
78
+ For policy-sensitive changes (caller scope, tool access, recursion, safety limits), require:
79
+ - explicit rationale in PR notes
80
+ - test updates in the same PR
81
+ - acceptance criteria updates when applicable
82
+
83
+ ## 9) Repo Intent
84
+
85
+ This repository is currently spec-and-foundation heavy. Prefer small, verifiable increments over broad changes.
86
+
87
+ When uncertain: choose stricter safety defaults and document the decision.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HenryLach
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.