@hassangameryt/freeflow 0.7.0 → 0.7.1

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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "freeflow",
12
12
  "description": "Feedback-based control for coding agents.",
13
- "version": "0.7.0",
13
+ "version": "0.7.1",
14
14
  "category": "development",
15
15
  "source": "./",
16
16
  "author": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "freeflow",
3
3
  "description": "Freeflow gives coding agents an Interaction Contract, adaptive Workflow, focused methods, and durable task memory.",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "author": {
6
6
  "name": "Hassan Mohiddin"
7
7
  },
@@ -9,6 +9,7 @@
9
9
  "repository": "https://github.com/hassan-mohiddin/freeflow",
10
10
  "license": "MIT",
11
11
  "skills": "./skills/",
12
+ "hooks": "./hooks/claude/hooks.json",
12
13
  "keywords": [
13
14
  "workflow",
14
15
  "skills",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freeflow",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Feedback-based control system for coding agents.",
5
5
  "author": {
6
6
  "name": "Hassan Mohiddin"
@@ -21,7 +21,7 @@
21
21
  "claude"
22
22
  ],
23
23
  "skills": "./skills/",
24
- "hooks": "./hooks/hooks.json",
24
+ "hooks": "./hooks/codex/hooks.json",
25
25
  "interface": {
26
26
  "displayName": "Freeflow",
27
27
  "shortDescription": "Feedback-based control for coding agents.",
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "freeflow",
3
+ "description": "Feedback-based control system for coding agents.",
4
+ "version": "0.7.1",
5
+ "author": {
6
+ "name": "Hassan Mohiddin"
7
+ },
8
+ "homepage": "https://github.com/hassan-mohiddin/freeflow",
9
+ "repository": "https://github.com/hassan-mohiddin/freeflow",
10
+ "license": "MIT",
11
+ "skills": "./skills/",
12
+ "hooks": "./hooks/cursor/hooks.json",
13
+ "logo": "assets/freeflow-plugin-icon-transparent.png",
14
+ "keywords": [
15
+ "workflow",
16
+ "skills",
17
+ "agents",
18
+ "feedback-loop",
19
+ "task-memory",
20
+ "planning",
21
+ "verification",
22
+ "review",
23
+ "cursor"
24
+ ]
25
+ }
package/README.md CHANGED
@@ -60,12 +60,18 @@ Freeflow is one package with different host boundaries:
60
60
 
61
61
  | Host | Freeflow support | Cognitive Routing |
62
62
  | --- | --- | --- |
63
- | Codex | Shared skills and lifecycle hook | Not available; Pi/PiFlow capabilities are not delivered |
64
- | Claude Code | Shared skills and lifecycle hook | Not available; Pi/PiFlow capabilities are not delivered |
63
+ | Codex | Shared skills and Codex `SessionStart` hook | Not available; Pi/PiFlow capabilities are not delivered |
64
+ | Claude Code | Shared skills and Claude Code `SessionStart` hook | Not available; Pi/PiFlow capabilities are not delivered |
65
+ | Gemini CLI | Gemini extension manifest, shared skills, and Gemini `SessionStart` hook | Not available; Pi/PiFlow capabilities are not delivered |
66
+ | Cursor | Agent Plugins 1.0 skills plus Cursor-specific hook delivery | Not available; Pi/PiFlow capabilities are not delivered |
67
+ | GitHub Copilot / VS Code | Agent Plugins 1.0 skills plus Copilot-specific hook delivery | Not available; Pi/PiFlow capabilities are not delivered |
68
+ | Kiro | Agent Plugins 1.0 Power and shared skills | Not available; no Kiro-specific runtime adapter is claimed |
69
+ | OpenCode v2 | Canonical `skills/` through OpenCode’s project skill source | Not available; skills-only support |
70
+ | Hermes Agent | Agent Plugins 1.0 package and canonical skills | Not available; skills-only support |
65
71
  | Pi | Shared skills, Pi extension, optional context capabilities, and native host controls | Available when configured and the official model-state APIs are present |
66
72
  | PiFlow | Freeflow package hosted by the separate PiFlow distribution | Available when configured |
67
73
 
68
- Freeflow owns workflow policy, prompt fragments, skills, capabilities, and the Pi extension. Each host owns its launch, package installation, session state, updates, and native model-state control; PiFlow remains a supported host integration.
74
+ Freeflow owns workflow policy, portable prompt fragments, the 25-skill distribution kernel, host adapters, Pi/PiFlow capabilities, and the Pi extension. Each host owns its launch, package installation, session state, updates, and native model-state control; PiFlow remains a supported host integration.
69
75
 
70
76
  ## Capabilities
71
77
 
@@ -106,6 +112,24 @@ Trust the Freeflow hook from `/hooks`, then start a new session.
106
112
  /reload-plugins
107
113
  ```
108
114
 
115
+ ### Gemini CLI
116
+
117
+ ```bash
118
+ gemini extensions install https://github.com/hassan-mohiddin/freeflow
119
+ ```
120
+
121
+ Restart Gemini CLI after installation or updates. The extension uses the root `gemini-extension.json`, shared `skills/`, and its Gemini-specific `SessionStart` hook adapter.
122
+
123
+ ### Cursor, GitHub Copilot, VS Code, Kiro, OpenCode, and Hermes Agent
124
+
125
+ These hosts consume the root Agent Plugins 1.0 manifest and the same `skills/` directory. Cursor also reads `.cursor-plugin/plugin.json` for its host-specific hook surface; GitHub Copilot and VS Code read the `com.github.copilot/` extension namespace. Install from each host’s documented plugin source or marketplace UI. For Copilot CLI, the direct repository form is:
126
+
127
+ ```bash
128
+ copilot plugin install hassan-mohiddin/freeflow
129
+ ```
130
+
131
+ VS Code provides **Chat: Install Plugin From Source**. Kiro exposes compatible Agent Plugins as Powers and can import a GitHub source through its Powers UI. OpenCode v2 can add the checkout or package’s `skills/` directory to the documented `skills` array in `opencode.json`; it does not receive a native Freeflow plugin runtime. Hermes can install the root portable package with `hermes plugins install hassan-mohiddin/freeflow --no-enable`, then enable it with `hermes plugins enable freeflow`, or use its documented skills source workflow. A host’s install/trust UI and marketplace availability are external to this repository’s deterministic checks.
132
+
109
133
  ### Pi
110
134
 
111
135
  ```bash
@@ -178,6 +202,8 @@ Read the full [System Prompt Architecture](https://github.com/hassan-mohiddin/fr
178
202
  Freeflow is explicit about what its checks prove:
179
203
 
180
204
  - deterministic checks prove structure, assembly, package boundaries, and selected delivery behavior;
205
+ - host-manifest and adapter checks prove the Agent Plugins 1.0, Gemini, Cursor, and Copilot/VS Code package shapes, not native host dispatch;
206
+ - OpenCode and Hermes compatibility checks cover the canonical Agent Skills/Agent Plugins package shape, not native host plugin APIs or runtime prompt delivery;
181
207
  - release evidence is versioned and records source, checks, artifacts, deferred evidence, and limits;
182
208
  - local installation does not prove remote host installation or registry propagation;
183
209
  - deterministic skill/runtime checks do not prove model behavior or universal skill readiness;
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "type": "command",
7
+ "command": "node \"${PLUGIN_ROOT}/hooks/adapters/copilot-session-start.mjs\" SessionStart"
8
+ }
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "freeflow",
3
+ "version": "0.7.1",
4
+ "description": "Feedback-based control system for coding agents."
5
+ }
@@ -0,0 +1,16 @@
1
+ import { isMainModule, runSafely, runSessionStartAdapter } from "../shared/runtime-context.mjs";
2
+
3
+ export function main() {
4
+ runSessionStartAdapter({
5
+ eventNames: ["SessionStart"],
6
+ getWorkspaceCwd: (input) => input.cwd || process.cwd(),
7
+ formatOutput: ({ eventName, additionalContext }) => ({
8
+ hookSpecificOutput: {
9
+ hookEventName: eventName,
10
+ additionalContext,
11
+ },
12
+ }),
13
+ });
14
+ }
15
+
16
+ if (isMainModule(import.meta.url)) runSafely(main);
@@ -0,0 +1,16 @@
1
+ import { isMainModule, runSafely, runSessionStartAdapter } from "../shared/runtime-context.mjs";
2
+
3
+ export function main() {
4
+ runSessionStartAdapter({
5
+ eventNames: ["SessionStart"],
6
+ getWorkspaceCwd: (input) => input.cwd || process.cwd(),
7
+ formatOutput: ({ eventName, additionalContext }) => ({
8
+ hookSpecificOutput: {
9
+ hookEventName: eventName,
10
+ additionalContext,
11
+ },
12
+ }),
13
+ });
14
+ }
15
+
16
+ if (isMainModule(import.meta.url)) runSafely(main);
@@ -0,0 +1,13 @@
1
+ import { isMainModule, runSafely, runSessionStartAdapter } from "../shared/runtime-context.mjs";
2
+
3
+ export function main() {
4
+ runSessionStartAdapter({
5
+ eventNames: ["SessionStart"],
6
+ getWorkspaceCwd: (input) => input.cwd || process.cwd(),
7
+ formatOutput: ({ additionalContext }) => ({
8
+ additionalContext,
9
+ }),
10
+ });
11
+ }
12
+
13
+ if (isMainModule(import.meta.url)) runSafely(main);
@@ -0,0 +1,13 @@
1
+ import { isMainModule, runSafely, runSessionStartAdapter } from "../shared/runtime-context.mjs";
2
+
3
+ export function main() {
4
+ runSessionStartAdapter({
5
+ eventNames: ["sessionStart", "SessionStart"],
6
+ getWorkspaceCwd: (input) => input.workspace_roots?.[0] || input.cwd || process.cwd(),
7
+ formatOutput: ({ additionalContext }) => ({
8
+ additional_context: additionalContext,
9
+ }),
10
+ });
11
+ }
12
+
13
+ if (isMainModule(import.meta.url)) runSafely(main);
@@ -0,0 +1,15 @@
1
+ import { isMainModule, runSafely, runSessionStartAdapter } from "../shared/runtime-context.mjs";
2
+
3
+ export function main() {
4
+ runSessionStartAdapter({
5
+ eventNames: ["SessionStart"],
6
+ getWorkspaceCwd: (input) => input.cwd || process.cwd(),
7
+ formatOutput: ({ additionalContext }) => ({
8
+ hookSpecificOutput: {
9
+ additionalContext,
10
+ },
11
+ }),
12
+ });
13
+ }
14
+
15
+ if (isMainModule(import.meta.url)) runSafely(main);
@@ -0,0 +1,18 @@
1
+ {
2
+ "description": "Load Freeflow core runtime context in Claude Code.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/adapters/claude-session-start.mjs\" SessionStart",
11
+ "timeout": 5,
12
+ "statusMessage": "Loading Freeflow context"
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "description": "Load Freeflow core runtime context in Codex.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${PLUGIN_ROOT}/hooks/adapters/codex-session-start.mjs\" SessionStart",
11
+ "timeout": 5,
12
+ "statusMessage": "Loading Freeflow context"
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "hooks": {
3
+ "sessionStart": [
4
+ {
5
+ "command": "node hooks/adapters/cursor-session-start.mjs SessionStart"
6
+ }
7
+ ]
8
+ }
9
+ }
@@ -1,218 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import process from "node:process";
6
- import { fileURLToPath } from "node:url";
3
+ import { main } from "./adapters/codex-session-start.mjs";
4
+ import { runSafely } from "./shared/runtime-context.mjs";
7
5
 
8
- const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
9
- const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "..");
10
-
11
- function readStdinJson() {
12
- const raw = fs.readFileSync(0, "utf8").trim();
13
- if (!raw) return { input: {}, raw: "" };
14
-
15
- try {
16
- return { input: JSON.parse(raw), raw };
17
- } catch {
18
- return { input: {}, raw };
19
- }
20
- }
21
-
22
- function readText(filePath) {
23
- try {
24
- const text = fs.readFileSync(filePath, "utf8").trim();
25
- return text || null;
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
- function findWorkspaceRoot(cwd) {
32
- let current = path.resolve(cwd || process.cwd());
33
-
34
- while (true) {
35
- if (fs.existsSync(path.join(current, ".git"))) return current;
36
-
37
- const parent = path.dirname(current);
38
- if (parent === current) return path.resolve(cwd || process.cwd());
39
- current = parent;
40
- }
41
- }
42
-
43
- function loadRuntimeContext() {
44
- const corePrompt = readText(path.join(PLUGIN_ROOT, "runtime", "prompts", "core.md"));
45
- const interactionContract = readText(path.join(PLUGIN_ROOT, "runtime", "prompts", "interaction-contract.md"));
46
-
47
- if (!corePrompt || !interactionContract) {
48
- throw new Error("Freeflow core runtime prompt files are missing.");
49
- }
50
-
51
- return { corePrompt, interactionContract };
52
- }
53
-
54
- function readConfig(root) {
55
- const repository = readConfigLayer(path.join(root, ".freeflow", "config.json"), isValidSetupConfig);
56
- const local = readConfigLayer(path.join(root, ".freeflow", "local.json"), isValidLocalConfig);
57
- const localValid = !local.exists || local.valid;
58
- const configured = repository.valid && localValid;
59
- const core = resolveCoreConfig(repository.valid ? repository.parsed : {}, local.valid ? local.parsed : {});
60
- const enabled = configured && core.config.enabled;
61
-
62
- return {
63
- exists: repository.exists,
64
- valid: configured,
65
- configured,
66
- repositoryValid: repository.valid,
67
- localExists: local.exists,
68
- localValid,
69
- error: repository.valid ? local.error : repository.error,
70
- enabled,
71
- contextVirtualizationEnabled: enabled && core.config.contextVirtualization,
72
- conversationHistoryEnabled: enabled && core.config.conversationHistory,
73
- };
74
- }
75
-
76
- function readConfigLayer(filePath, validate) {
77
- if (!fs.existsSync(filePath)) {
78
- return { path: filePath, exists: false, valid: false, parsed: {}, error: null };
79
- }
80
-
81
- try {
82
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
83
- const error = validate(parsed);
84
- return {
85
- path: filePath,
86
- exists: true,
87
- valid: error === null,
88
- parsed: error === null ? parsed : {},
89
- error,
90
- };
91
- } catch (error) {
92
- return {
93
- path: filePath,
94
- exists: true,
95
- valid: false,
96
- parsed: {},
97
- error: error instanceof Error ? error.message : String(error),
98
- };
99
- }
100
- }
101
-
102
- function isRecord(value) {
103
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
104
- }
105
-
106
- function validateCoreConfigFields(value) {
107
- if (Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean") {
108
- return "enabled must be a boolean";
109
- }
110
- if (Object.hasOwn(value, "contextVirtualization") && typeof value.contextVirtualization !== "boolean") {
111
- return "contextVirtualization must be a boolean";
112
- }
113
- if (Object.hasOwn(value, "conversationHistory") && typeof value.conversationHistory !== "boolean") {
114
- return "conversationHistory must be a boolean";
115
- }
116
- return null;
117
- }
118
-
119
- function isValidSetupConfig(value) {
120
- if (!isRecord(value)) return "repository config must be a JSON object";
121
-
122
- const allowedKeys = new Set([
123
- "enabled",
124
- "contextVirtualization",
125
- "conversationHistory",
126
- "outputRouter",
127
- "observedRouting",
128
- "scriptTransform",
129
- "cognitiveRouting",
130
- ]);
131
- if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
132
- return "repository config contains unsupported top-level keys";
133
- }
134
-
135
- const coreError = validateCoreConfigFields(value);
136
- if (coreError) return coreError;
137
-
138
- for (const key of ["outputRouter", "observedRouting", "scriptTransform"]) {
139
- if (Object.hasOwn(value, key) && !isRecord(value[key])) return `${key} must be an object`;
140
- }
141
- return null;
142
- }
143
-
144
- function isValidLocalConfig(value) {
145
- if (!isRecord(value)) return "local config must be a JSON object";
146
-
147
- const allowedKeys = new Set([
148
- "enabled",
149
- "processing",
150
- "cognitiveRouting",
151
- "contextVirtualization",
152
- "conversationHistory",
153
- ]);
154
- if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
155
- return "local config contains unsupported top-level keys";
156
- }
157
- return validateCoreConfigFields(value);
158
- }
159
-
160
- function resolveLayeredValue(repository, local, key, fallback) {
161
- if (Object.hasOwn(local, key)) return local[key];
162
- if (Object.hasOwn(repository, key)) return repository[key];
163
- return fallback;
164
- }
165
-
166
- function resolveCoreConfig(repository, local) {
167
- return {
168
- config: {
169
- enabled: resolveLayeredValue(repository, local, "enabled", true),
170
- contextVirtualization: resolveLayeredValue(repository, local, "contextVirtualization", false),
171
- conversationHistory: resolveLayeredValue(repository, local, "conversationHistory", false),
172
- },
173
- };
174
- }
175
-
176
- function runtimeStateContext() {
177
- return ["# Freeflow Runtime State", "", "Freeflow: active"].join("\n");
178
- }
179
-
180
- function renderSessionStart(config) {
181
- if (!config.valid || !config.enabled) return "";
182
-
183
- const { corePrompt, interactionContract } = loadRuntimeContext();
184
- return [corePrompt.trim(), interactionContract.trim(), runtimeStateContext()].filter(Boolean).join("\n\n");
185
- }
186
-
187
- function shouldHandle(eventName) {
188
- if (process.env.FREEFLOW_DISABLE_RUNTIME_CONTEXT === "1") return false;
189
- return eventName === "SessionStart";
190
- }
191
-
192
- function emitAdditionalContext(eventName, additionalContext) {
193
- process.stdout.write(
194
- `${JSON.stringify({
195
- hookSpecificOutput: {
196
- hookEventName: eventName,
197
- additionalContext,
198
- },
199
- })}\n`,
200
- );
201
- }
202
-
203
- function main() {
204
- const { input, raw } = readStdinJson();
205
- const eventName = process.argv[2] || input.hook_event_name || "";
206
-
207
- if ((!raw && !eventName) || !shouldHandle(eventName)) return;
208
-
209
- const root = findWorkspaceRoot(input.cwd || process.cwd());
210
- const additionalContext = renderSessionStart(readConfig(root));
211
- if (additionalContext.trim()) emitAdditionalContext(eventName, additionalContext);
212
- }
213
-
214
- try {
215
- main();
216
- } catch (error) {
217
- process.stderr.write(`Freeflow runtime context hook skipped: ${error.message}\n`);
218
- }
6
+ runSafely(main);
package/hooks/hooks.json CHANGED
@@ -1,15 +1,14 @@
1
1
  {
2
- "description": "Load Freeflow core runtime context.",
2
+ "description": "Load Freeflow core runtime context in Gemini CLI.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
6
- "matcher": "startup|resume|clear|compact",
7
6
  "hooks": [
8
7
  {
9
8
  "type": "command",
10
- "command": "node \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}/hooks/freeflow-runtime-context.mjs\" SessionStart",
11
- "timeout": 5,
12
- "statusMessage": "Loading Freeflow context"
9
+ "name": "freeflow-runtime-context",
10
+ "command": "node \"${extensionPath}/hooks/adapters/gemini-session-start.mjs\" SessionStart",
11
+ "timeout": 5000
13
12
  }
14
13
  ]
15
14
  }
@@ -0,0 +1,210 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
7
+ export const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "../..");
8
+
9
+ export function readHookInput() {
10
+ const raw = fs.readFileSync(0, "utf8").trim();
11
+ if (!raw) return { input: {}, raw: "" };
12
+
13
+ try {
14
+ return { input: JSON.parse(raw), raw };
15
+ } catch {
16
+ return { input: {}, raw };
17
+ }
18
+ }
19
+
20
+ function readText(filePath) {
21
+ try {
22
+ const text = fs.readFileSync(filePath, "utf8").trim();
23
+ return text || null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function findWorkspaceRoot(cwd) {
30
+ let current = path.resolve(cwd || process.cwd());
31
+
32
+ while (true) {
33
+ if (fs.existsSync(path.join(current, ".git"))) return current;
34
+
35
+ const parent = path.dirname(current);
36
+ if (parent === current) return path.resolve(cwd || process.cwd());
37
+ current = parent;
38
+ }
39
+ }
40
+
41
+ function readConfigLayer(filePath, validate) {
42
+ if (!fs.existsSync(filePath)) {
43
+ return { exists: false, valid: false, parsed: {}, error: null };
44
+ }
45
+
46
+ try {
47
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
48
+ const error = validate(parsed);
49
+ return {
50
+ exists: true,
51
+ valid: error === null,
52
+ parsed: error === null ? parsed : {},
53
+ error,
54
+ };
55
+ } catch (error) {
56
+ return {
57
+ exists: true,
58
+ valid: false,
59
+ parsed: {},
60
+ error: error instanceof Error ? error.message : String(error),
61
+ };
62
+ }
63
+ }
64
+
65
+ function isRecord(value) {
66
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
67
+ }
68
+
69
+ function validateCoreConfigFields(value) {
70
+ if (Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean") {
71
+ return "enabled must be a boolean";
72
+ }
73
+ if (Object.hasOwn(value, "contextVirtualization") && typeof value.contextVirtualization !== "boolean") {
74
+ return "contextVirtualization must be a boolean";
75
+ }
76
+ if (Object.hasOwn(value, "conversationHistory") && typeof value.conversationHistory !== "boolean") {
77
+ return "conversationHistory must be a boolean";
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function isValidSetupConfig(value) {
83
+ if (!isRecord(value)) return "repository config must be a JSON object";
84
+
85
+ const allowedKeys = new Set([
86
+ "enabled",
87
+ "contextVirtualization",
88
+ "conversationHistory",
89
+ "outputRouter",
90
+ "observedRouting",
91
+ "scriptTransform",
92
+ "cognitiveRouting",
93
+ ]);
94
+ if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
95
+ return "repository config contains unsupported top-level keys";
96
+ }
97
+
98
+ const coreError = validateCoreConfigFields(value);
99
+ if (coreError) return coreError;
100
+
101
+ for (const key of ["outputRouter", "observedRouting", "scriptTransform"]) {
102
+ if (Object.hasOwn(value, key) && !isRecord(value[key])) return `${key} must be an object`;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function isValidLocalConfig(value) {
108
+ if (!isRecord(value)) return "local config must be a JSON object";
109
+
110
+ const allowedKeys = new Set([
111
+ "enabled",
112
+ "processing",
113
+ "cognitiveRouting",
114
+ "contextVirtualization",
115
+ "conversationHistory",
116
+ ]);
117
+ if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
118
+ return "local config contains unsupported top-level keys";
119
+ }
120
+ return validateCoreConfigFields(value);
121
+ }
122
+
123
+ function resolveLayeredValue(repository, local, key, fallback) {
124
+ if (Object.hasOwn(local, key)) return local[key];
125
+ if (Object.hasOwn(repository, key)) return repository[key];
126
+ return fallback;
127
+ }
128
+
129
+ function resolveCoreConfig(repository, local) {
130
+ return {
131
+ enabled: resolveLayeredValue(repository, local, "enabled", true),
132
+ contextVirtualization: resolveLayeredValue(repository, local, "contextVirtualization", false),
133
+ conversationHistory: resolveLayeredValue(repository, local, "conversationHistory", false),
134
+ };
135
+ }
136
+
137
+ export function readConfig(root) {
138
+ const repository = readConfigLayer(path.join(root, ".freeflow", "config.json"), isValidSetupConfig);
139
+ const local = readConfigLayer(path.join(root, ".freeflow", "local.json"), isValidLocalConfig);
140
+ const localValid = !local.exists || local.valid;
141
+ const configured = repository.valid && localValid;
142
+ const core = resolveCoreConfig(repository.valid ? repository.parsed : {}, local.valid ? local.parsed : {});
143
+ const enabled = configured && core.enabled;
144
+
145
+ return {
146
+ exists: repository.exists,
147
+ valid: configured,
148
+ configured,
149
+ repositoryValid: repository.valid,
150
+ localExists: local.exists,
151
+ localValid,
152
+ error: repository.valid ? local.error : repository.error,
153
+ enabled,
154
+ contextVirtualizationEnabled: enabled && core.contextVirtualization,
155
+ conversationHistoryEnabled: enabled && core.conversationHistory,
156
+ };
157
+ }
158
+
159
+ function loadRuntimeContext() {
160
+ const corePrompt = readText(path.join(PLUGIN_ROOT, "runtime", "prompts", "core.md"));
161
+ const interactionContract = readText(path.join(PLUGIN_ROOT, "runtime", "prompts", "interaction-contract.md"));
162
+
163
+ if (!corePrompt || !interactionContract) {
164
+ throw new Error("Freeflow core runtime prompt files are missing.");
165
+ }
166
+
167
+ return { corePrompt, interactionContract };
168
+ }
169
+
170
+ function runtimeStateContext() {
171
+ return ["# Freeflow Runtime State", "", "Freeflow: active"].join("\n");
172
+ }
173
+
174
+ export function renderRuntimeContext(root) {
175
+ const config = readConfig(root);
176
+ if (!config.valid || !config.enabled) return "";
177
+
178
+ const { corePrompt, interactionContract } = loadRuntimeContext();
179
+ return [corePrompt, interactionContract, runtimeStateContext()].filter(Boolean).join("\n\n");
180
+ }
181
+
182
+ export function emitHookOutput(output) {
183
+ process.stdout.write(`${JSON.stringify(output)}\n`);
184
+ }
185
+
186
+ export function runSessionStartAdapter({ eventNames, getWorkspaceCwd, formatOutput }) {
187
+ const { input, raw } = readHookInput();
188
+ const eventName = process.argv[2] || input.hook_event_name || "";
189
+ if ((!raw && !eventName) || process.env.FREEFLOW_DISABLE_RUNTIME_CONTEXT === "1") return;
190
+ if (!eventNames.includes(eventName)) return;
191
+
192
+ const root = findWorkspaceRoot(getWorkspaceCwd(input));
193
+ const additionalContext = renderRuntimeContext(root);
194
+ if (additionalContext.trim()) {
195
+ emitHookOutput(formatOutput({ eventName, input, additionalContext }));
196
+ }
197
+ }
198
+
199
+ export function runSafely(run) {
200
+ try {
201
+ run();
202
+ } catch (error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ process.stderr.write(`Freeflow runtime context hook skipped: ${message}\n`);
205
+ }
206
+ }
207
+
208
+ export function isMainModule(metaUrl) {
209
+ return process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === metaUrl;
210
+ }
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import test from "node:test";
7
+
8
+ const repoRoot = resolve(import.meta.dirname, "../..");
9
+ const adapters = {
10
+ legacy: ["hooks/freeflow-runtime-context.mjs", "hookSpecificOutput.additionalContext"],
11
+ claude: ["hooks/adapters/claude-session-start.mjs", "hookSpecificOutput.additionalContext"],
12
+ codex: ["hooks/adapters/codex-session-start.mjs", "hookSpecificOutput.additionalContext"],
13
+ gemini: ["hooks/adapters/gemini-session-start.mjs", "hookSpecificOutput.additionalContext"],
14
+ copilot: ["hooks/adapters/copilot-session-start.mjs", "additionalContext"],
15
+ cursor: ["hooks/adapters/cursor-session-start.mjs", "additional_context"],
16
+ };
17
+
18
+ function createWorkspace(config = "{}\n") {
19
+ const root = mkdtempSync(join(tmpdir(), "freeflow-host-adapter-"));
20
+ mkdirSync(join(root, ".freeflow"), { recursive: true });
21
+ writeFileSync(join(root, ".freeflow", "config.json"), config, "utf8");
22
+ return root;
23
+ }
24
+
25
+ function runAdapter(relativePath, workspace, input, event = "SessionStart") {
26
+ const result = spawnSync(process.execPath, [join(repoRoot, relativePath), event], {
27
+ cwd: repoRoot,
28
+ input: `${JSON.stringify(input)}\n`,
29
+ encoding: "utf8",
30
+ });
31
+ assert.equal(result.status, 0, `${relativePath} stderr: ${result.stderr}`);
32
+ assert.equal(result.stderr, "", `${relativePath} should not log on the happy path`);
33
+ return result.stdout.trim() ? JSON.parse(result.stdout) : undefined;
34
+ }
35
+
36
+ test("host adapters share rendered mandatory context but emit host-native envelopes", () => {
37
+ const workspace = createWorkspace();
38
+ const outputs = {};
39
+ for (const [host, [relativePath]] of Object.entries(adapters)) {
40
+ const input =
41
+ host === "cursor"
42
+ ? { hook_event_name: "sessionStart", workspace_roots: [workspace] }
43
+ : { hook_event_name: "SessionStart", cwd: workspace };
44
+ outputs[host] = runAdapter(relativePath, workspace, input, host === "cursor" ? "sessionStart" : "SessionStart");
45
+ assert.ok(outputs[host], `${host} should emit context`);
46
+ }
47
+
48
+ const legacyContext = outputs.legacy.hookSpecificOutput.additionalContext;
49
+ for (const host of ["claude", "codex", "gemini"]) {
50
+ assert.equal(outputs[host].hookSpecificOutput.additionalContext, legacyContext);
51
+ }
52
+ assert.deepEqual(Object.keys(outputs.copilot).sort(), ["additionalContext"]);
53
+ assert.equal(outputs.copilot.additionalContext, legacyContext);
54
+ assert.equal(outputs.copilot.hookSpecificOutput, undefined);
55
+ assert.equal(outputs.cursor.additional_context, legacyContext);
56
+ assert.equal(outputs.cursor.hookSpecificOutput, undefined);
57
+ assert.match(legacyContext, /# Freeflow Stable Guidance/);
58
+ assert.match(legacyContext, /# Freeflow Interaction Contract/);
59
+ assert.match(legacyContext, /Freeflow: active/);
60
+ assert.doesNotMatch(legacyContext, /Cognitive Routing/);
61
+ });
62
+
63
+ test("host adapters stay inert for disabled, invalid, and unsupported lifecycle inputs", () => {
64
+ const disabledWorkspace = createWorkspace('{"enabled":false}\n');
65
+ const invalidWorkspace = createWorkspace('{"enabled":"no"}\n');
66
+ for (const [host, [relativePath]] of Object.entries(adapters)) {
67
+ const input =
68
+ host === "cursor"
69
+ ? { hook_event_name: "sessionStart", workspace_roots: [disabledWorkspace] }
70
+ : { hook_event_name: "SessionStart", cwd: disabledWorkspace };
71
+ assert.equal(
72
+ runAdapter(relativePath, disabledWorkspace, input, host === "cursor" ? "sessionStart" : "SessionStart"),
73
+ undefined,
74
+ );
75
+ const invalidInput = { ...input, cwd: invalidWorkspace, workspace_roots: [invalidWorkspace] };
76
+ assert.equal(
77
+ runAdapter(relativePath, invalidWorkspace, invalidInput, host === "cursor" ? "sessionStart" : "SessionStart"),
78
+ undefined,
79
+ );
80
+ assert.equal(runAdapter(relativePath, disabledWorkspace, input, "UserPromptSubmit"), undefined);
81
+ }
82
+ });
@@ -3,7 +3,11 @@ set -euo pipefail
3
3
 
4
4
  ROOT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
5
5
  HOOK_PATH="$ROOT_DIR/hooks/freeflow-runtime-context.mjs"
6
- HOOKS_JSON="$ROOT_DIR/hooks/hooks.json"
6
+ CLAUDE_HOOKS_JSON="$ROOT_DIR/hooks/claude/hooks.json"
7
+ CODEX_HOOKS_JSON="$ROOT_DIR/hooks/codex/hooks.json"
8
+ GEMINI_HOOKS_JSON="$ROOT_DIR/hooks/hooks.json"
9
+ CURSOR_HOOKS_JSON="$ROOT_DIR/hooks/cursor/hooks.json"
10
+ COPILOT_HOOKS_JSON="$ROOT_DIR/com.github.copilot/hooks/hooks.json"
7
11
  CORE_PROMPT="$ROOT_DIR/runtime/prompts/core.md"
8
12
  INTERACTION_PROMPT="$ROOT_DIR/runtime/prompts/interaction-contract.md"
9
13
 
@@ -29,7 +33,9 @@ assert_not_contains() {
29
33
  }
30
34
 
31
35
  [[ -f "$HOOK_PATH" ]] || fail "missing hook script"
32
- [[ -f "$HOOKS_JSON" ]] || fail "missing hooks manifest"
36
+ for hooks_manifest in "$CLAUDE_HOOKS_JSON" "$CODEX_HOOKS_JSON" "$GEMINI_HOOKS_JSON" "$CURSOR_HOOKS_JSON" "$COPILOT_HOOKS_JSON"; do
37
+ [[ -f "$hooks_manifest" ]] || fail "missing hooks manifest: $hooks_manifest"
38
+ done
33
39
  [[ -f "$CORE_PROMPT" ]] || fail "missing core prompt"
34
40
  [[ -f "$INTERACTION_PROMPT" ]] || fail "missing interaction contract prompt"
35
41
  [[ ! -f "$ROOT_DIR/runtime/prompts/skills.md" ]] || fail "retired skills prompt remains"
@@ -40,7 +46,29 @@ const hooks = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).hooks || {};
40
46
  if (!Array.isArray(hooks.SessionStart) || hooks.SessionStart.length !== 1) process.exit(1);
41
47
  if (hooks.SessionStart[0]?.matcher !== "startup|resume|clear|compact") process.exit(1);
42
48
  if (hooks.UserPromptSubmit || hooks.SessionEnd) process.exit(1);
43
- ' "$HOOKS_JSON" || fail "hooks.json does not expose only SessionStart"
49
+ ' "$CODEX_HOOKS_JSON" || fail "Codex hooks manifest does not expose only SessionStart"
50
+ node -e '
51
+ const fs = require("fs");
52
+ const hooks = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).hooks || {};
53
+ if (!Array.isArray(hooks.SessionStart) || hooks.SessionStart.length !== 1) process.exit(1);
54
+ if (hooks.SessionStart[0]?.matcher !== "startup|resume|clear|compact") process.exit(1);
55
+ ' "$CLAUDE_HOOKS_JSON" || fail "Claude hooks manifest does not preserve lifecycle starts"
56
+ node -e '
57
+ const fs = require("fs");
58
+ const hooks = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).hooks || {};
59
+ if (!Array.isArray(hooks.SessionStart) || hooks.SessionStart.length !== 1) process.exit(1);
60
+ if (Object.prototype.hasOwnProperty.call(hooks.SessionStart[0], "matcher")) process.exit(1);
61
+ ' "$GEMINI_HOOKS_JSON" || fail "Gemini hooks manifest does not expose SessionStart"
62
+ node -e '
63
+ const fs = require("fs");
64
+ const hooks = JSON.parse(fs.readFileSync(process.argv[1], "utf8")).hooks || {};
65
+ if (!Array.isArray(hooks.sessionStart) || hooks.sessionStart.length !== 1) process.exit(1);
66
+ ' "$CURSOR_HOOKS_JSON" || fail "Cursor hooks manifest does not expose sessionStart"
67
+ node -e '
68
+ const fs = require("fs");
69
+ const config = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
70
+ if (config.version !== 1 || !Array.isArray(config.hooks?.SessionStart) || config.hooks.SessionStart.length !== 1) process.exit(1);
71
+ ' "$COPILOT_HOOKS_JSON" || fail "Copilot hooks manifest does not expose versioned SessionStart"
44
72
 
45
73
  tmp_dir="$(mktemp -d)"
46
74
  trap 'rm -rf "$tmp_dir"' EXIT
@@ -81,6 +109,9 @@ assert_not_contains "$codex_output" "session-modes" "configured core context"
81
109
  whitespace_root="$(mktemp -d)"
82
110
  mkdir -p "$whitespace_root/hooks" "$whitespace_root/runtime/prompts" "$whitespace_root/repo/.freeflow"
83
111
  cp "$HOOK_PATH" "$whitespace_root/hooks/freeflow-runtime-context.mjs"
112
+ mkdir -p "$whitespace_root/hooks/adapters" "$whitespace_root/hooks/shared"
113
+ cp "$ROOT_DIR/hooks/adapters/codex-session-start.mjs" "$whitespace_root/hooks/adapters/codex-session-start.mjs"
114
+ cp "$ROOT_DIR/hooks/shared/runtime-context.mjs" "$whitespace_root/hooks/shared/runtime-context.mjs"
84
115
  cp -R "$ROOT_DIR/runtime/prompts/." "$whitespace_root/runtime/prompts/"
85
116
  printf ' \n\t\n' >"$whitespace_root/runtime/prompts/core.md"
86
117
  printf '{}\n' >"$whitespace_root/repo/.freeflow/config.json"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hassangameryt/freeflow",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Feedback-based control system for coding agents.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,8 +22,9 @@
22
22
  "build:pi-extension": "tsc -p pi-extension/tsconfig.json && npm run format:pi-extension-dist",
23
23
  "build": "npm run build:pi-extension",
24
24
  "typecheck": "tsc -p pi-extension/tsconfig.json --noEmit",
25
- "check": "npm run typecheck && npm run check:source && npm run check:changelog && npm run check:release-workflow && npm run check:docs && npm run format:check && npm run check:release-version && npm run check:commands && npm run check:activation && npm run check:runtime && npm run check:package",
25
+ "check": "npm run typecheck && npm run check:source && npm run check:host-manifests && npm run check:changelog && npm run check:release-workflow && npm run check:docs && npm run format:check && npm run check:release-version && npm run check:commands && npm run check:activation && npm run check:runtime && npm run check:package",
26
26
  "check:source": "node scripts/validation/check-source-files.mjs",
27
+ "check:host-manifests": "node scripts/validation/check-host-manifests.mjs",
27
28
  "check:changelog": "node scripts/validation/check-changelog.mjs --structure",
28
29
  "check:release-workflow": "node scripts/validation/check-release-workflow.mjs",
29
30
  "check:docs": "node scripts/validation/check-docs.mjs",
@@ -34,6 +35,7 @@
34
35
  "check:package": "node scripts/validation/check-package.mjs",
35
36
  "check:release-metadata": "scripts/validation/validate-release-metadata.sh --mode prepublish",
36
37
  "test:pi-extension": "npm run build && node --test $(find pi-extension/tests -name '*.test.js' | sort)",
38
+ "test:host-manifests": "node --test scripts/validation/check-host-manifests.test.mjs hooks/tests/check-host-adapters.test.mjs",
37
39
  "test:skill-author": "node --test $(find .skill-eval/write-skill/tests -name '*.test.mjs' | sort)",
38
40
  "test:skill-eval": "node --test $(find .skill-eval/evaluate-skill/tests -name '*.test.mjs' | sort)",
39
41
  "test:track-work": "node --test .skill-eval/track-work/tests/*.test.mjs",
@@ -59,7 +61,15 @@
59
61
  "review",
60
62
  "codex",
61
63
  "claude",
62
- "pi"
64
+ "pi",
65
+ "agent-plugins",
66
+ "gemini",
67
+ "cursor",
68
+ "copilot",
69
+ "vscode",
70
+ "kiro",
71
+ "opencode",
72
+ "hermes"
63
73
  ],
64
74
  "pi": {
65
75
  "skills": [],
@@ -69,9 +79,13 @@
69
79
  "image": "https://raw.githubusercontent.com/hassan-mohiddin/freeflow/main/assets/freeflow-plugin-icon-transparent.png"
70
80
  },
71
81
  "files": [
82
+ "plugin.json",
83
+ "gemini-extension.json",
72
84
  ".agents/**",
73
85
  ".claude-plugin/**",
74
86
  ".codex-plugin/**",
87
+ ".cursor-plugin/**",
88
+ "com.github.copilot/**",
75
89
  "LICENSE",
76
90
  "README.md",
77
91
  "assets/**",
package/plugin.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "freeflow",
4
+ "version": "0.7.1",
5
+ "description": "Feedback-based control system for coding agents.",
6
+ "author": {
7
+ "name": "Hassan Mohiddin"
8
+ },
9
+ "homepage": "https://github.com/hassan-mohiddin/freeflow",
10
+ "repository": "https://github.com/hassan-mohiddin/freeflow",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "workflow",
14
+ "skills",
15
+ "agents",
16
+ "feedback-loop",
17
+ "task-memory",
18
+ "planning",
19
+ "verification",
20
+ "review",
21
+ "agent-plugins",
22
+ "gemini",
23
+ "copilot",
24
+ "vscode",
25
+ "cursor",
26
+ "kiro",
27
+ "opencode",
28
+ "hermes"
29
+ ],
30
+ "extensions": {
31
+ "com.github.copilot": {}
32
+ }
33
+ }