@hassangameryt/freeflow 0.4.0 → 0.5.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -1
- package/README.md +20 -19
- package/{skills → capabilities}/output-router/SKILL.md +1 -1
- package/{skills/setup-freeflow/references/output-router-setup.md → capabilities/output-router/references/setup.md} +1 -1
- package/command-surface.json +11 -0
- package/hooks/freeflow-runtime-context.mjs +398 -108
- package/hooks/hooks.json +26 -1
- package/hooks/tests/check-runtime-context-hook.sh +178 -26
- package/package.json +2 -1
- package/pi-extension/dist/output-router/native-safety-net.js +1 -1
- package/pi-extension/dist/runtime/runtime-context.js +1 -1
- package/pi-extension/src/output-router/native-safety-net.ts +1 -1
- package/pi-extension/src/runtime/runtime-context.ts +1 -1
- package/pi-extension/tests/integration/pi-extension.test.js +2 -0
- package/router/dist/config/config.d.ts +1 -1
- package/router/dist/config/config.js +1 -1
- package/router/dist/tools/run.js +1 -1
- package/skills/mode-contract/SKILL.md +28 -6
- package/skills/setup-freeflow/SKILL.md +3 -9
- package/skills/setup-freeflow/references/activation-contract.md +4 -9
- package/skills/setup-freeflow/references/host-setup.md +2 -6
- /package/{skills → capabilities}/output-router/references/safety-policy.md +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import process from "node:process";
|
|
@@ -8,6 +9,9 @@ import { fileURLToPath } from "node:url";
|
|
|
8
9
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "..");
|
|
10
11
|
const VALID_MODES = new Set(["conversation", "workflow", "strict-workflow"]);
|
|
12
|
+
const SESSION_STATE_VERSION = 1;
|
|
13
|
+
const SESSION_STATE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const CLEAR_TRANSFER_MAX_AGE_MS = 60 * 1000;
|
|
11
15
|
|
|
12
16
|
function readStdinJson() {
|
|
13
17
|
const raw = fs.readFileSync(0, "utf8").trim();
|
|
@@ -49,24 +53,16 @@ function findWorkspaceRoot(cwd) {
|
|
|
49
53
|
function loadRuntimeContext(options = {}) {
|
|
50
54
|
const includeInteractionContract = options.interactionContract === true;
|
|
51
55
|
const includeSkills = options.skills === true;
|
|
52
|
-
const includeOutputRouter = options.outputRouter === true;
|
|
53
56
|
const interactionContract = includeInteractionContract
|
|
54
57
|
? readText(path.join(PLUGIN_ROOT, "runtime", "interaction-contract.md"))
|
|
55
58
|
: null;
|
|
56
59
|
const workflowSkill = includeSkills ? readText(path.join(PLUGIN_ROOT, "skills", "workflow", "SKILL.md")) : null;
|
|
57
|
-
const outputRouterSkill = includeOutputRouter
|
|
58
|
-
? readText(path.join(PLUGIN_ROOT, "skills", "output-router", "SKILL.md"))
|
|
59
|
-
: null;
|
|
60
60
|
|
|
61
|
-
if (
|
|
62
|
-
(includeInteractionContract && !interactionContract) ||
|
|
63
|
-
(includeSkills && !workflowSkill) ||
|
|
64
|
-
(includeOutputRouter && !outputRouterSkill)
|
|
65
|
-
) {
|
|
61
|
+
if ((includeInteractionContract && !interactionContract) || (includeSkills && !workflowSkill)) {
|
|
66
62
|
throw new Error("Freeflow runtime context files are missing.");
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
return { interactionContract, workflowSkill
|
|
65
|
+
return { interactionContract, workflowSkill };
|
|
70
66
|
}
|
|
71
67
|
|
|
72
68
|
function readConfig(root) {
|
|
@@ -76,7 +72,6 @@ function readConfig(root) {
|
|
|
76
72
|
const configured = repository.valid && localValid;
|
|
77
73
|
const core = resolveCoreConfig(repository.valid ? repository.parsed : {}, local.valid ? local.parsed : {});
|
|
78
74
|
const enabled = configured && core.config.enabled;
|
|
79
|
-
const repositoryConfig = repository.valid ? repository.parsed : {};
|
|
80
75
|
|
|
81
76
|
return {
|
|
82
77
|
exists: repository.exists,
|
|
@@ -91,7 +86,6 @@ function readConfig(root) {
|
|
|
91
86
|
skillsEnabled: enabled && core.config.skills.enabled,
|
|
92
87
|
defaultMode: core.config.defaultMode,
|
|
93
88
|
sources: core.sources,
|
|
94
|
-
outputRouterEnabled: enabled && repositoryConfig?.outputRouter?.enabled === true,
|
|
95
89
|
};
|
|
96
90
|
}
|
|
97
91
|
|
|
@@ -126,23 +120,20 @@ function isRecord(value) {
|
|
|
126
120
|
}
|
|
127
121
|
|
|
128
122
|
function validateCoreConfigFields(value) {
|
|
129
|
-
if (Object.
|
|
123
|
+
if (Object.hasOwn(value, "defaultMode") && !VALID_MODES.has(value.defaultMode)) {
|
|
130
124
|
return "defaultMode must be conversation, workflow, or strict-workflow";
|
|
131
125
|
}
|
|
132
|
-
if (Object.
|
|
126
|
+
if (Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean") {
|
|
133
127
|
return "enabled must be a boolean";
|
|
134
128
|
}
|
|
135
|
-
if (
|
|
136
|
-
Object.prototype.hasOwnProperty.call(value, "interactionContract") &&
|
|
137
|
-
typeof value.interactionContract !== "boolean"
|
|
138
|
-
) {
|
|
129
|
+
if (Object.hasOwn(value, "interactionContract") && typeof value.interactionContract !== "boolean") {
|
|
139
130
|
return "interactionContract must be a boolean";
|
|
140
131
|
}
|
|
141
|
-
if (Object.
|
|
132
|
+
if (Object.hasOwn(value, "skills")) {
|
|
142
133
|
if (!isRecord(value.skills)) {
|
|
143
134
|
return "skills must be an object";
|
|
144
135
|
}
|
|
145
|
-
if (Object.
|
|
136
|
+
if (Object.hasOwn(value.skills, "enabled") && typeof value.skills.enabled !== "boolean") {
|
|
146
137
|
return "skills.enabled must be a boolean";
|
|
147
138
|
}
|
|
148
139
|
}
|
|
@@ -173,7 +164,7 @@ function isValidSetupConfig(value) {
|
|
|
173
164
|
}
|
|
174
165
|
|
|
175
166
|
for (const key of ["outputRouter", "observedRouting", "scriptTransform"]) {
|
|
176
|
-
if (Object.
|
|
167
|
+
if (Object.hasOwn(value, key) && !isRecord(value[key])) {
|
|
177
168
|
return `${key} must be an object`;
|
|
178
169
|
}
|
|
179
170
|
}
|
|
@@ -193,7 +184,7 @@ function isValidLocalConfig(value) {
|
|
|
193
184
|
}
|
|
194
185
|
|
|
195
186
|
function hasOwn(value, key) {
|
|
196
|
-
return Object.
|
|
187
|
+
return Object.hasOwn(value, key);
|
|
197
188
|
}
|
|
198
189
|
|
|
199
190
|
function resolveLayeredValue(repository, local, key, fallback) {
|
|
@@ -230,17 +221,246 @@ function resolveCoreConfig(repository, local) {
|
|
|
230
221
|
};
|
|
231
222
|
}
|
|
232
223
|
|
|
233
|
-
function
|
|
234
|
-
return
|
|
224
|
+
function hostName() {
|
|
225
|
+
return process.env.PLUGIN_DATA ? "codex" : "claude";
|
|
235
226
|
}
|
|
236
227
|
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
|
|
228
|
+
function pluginDataRoot() {
|
|
229
|
+
const configured = process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA;
|
|
230
|
+
return configured ? path.resolve(configured) : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sessionStatePath(sessionId) {
|
|
234
|
+
const dataRoot = pluginDataRoot();
|
|
235
|
+
if (!dataRoot || typeof sessionId !== "string" || sessionId.length === 0) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const key = crypto.createHash("sha256").update(`${hostName()}\0${sessionId}`).digest("hex");
|
|
240
|
+
return path.join(dataRoot, "session-modes", hostName(), `${key}.json`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function workspaceKey(root) {
|
|
244
|
+
return crypto.createHash("sha256").update(path.resolve(root)).digest("hex");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function claudeProcessKey() {
|
|
248
|
+
const processId = process.env.CLAUDE_PID;
|
|
249
|
+
if (typeof processId !== "string" || processId.length === 0) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return crypto.createHash("sha256").update(processId).digest("hex");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function clearTransferPath(root) {
|
|
256
|
+
const dataRoot = pluginDataRoot();
|
|
257
|
+
const processKey = claudeProcessKey();
|
|
258
|
+
if (!dataRoot || hostName() !== "claude" || !processKey) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return path.join(dataRoot, "session-modes", "claude-clear", `${workspaceKey(root)}-${processKey}.json`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function writeJsonAtomic(filePath, value) {
|
|
265
|
+
const directory = path.dirname(filePath);
|
|
266
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
267
|
+
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
268
|
+
try {
|
|
269
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
270
|
+
fs.renameSync(temporaryPath, filePath);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
try {
|
|
273
|
+
fs.unlinkSync(temporaryPath);
|
|
274
|
+
} catch {
|
|
275
|
+
// Best-effort cleanup of a failed atomic write.
|
|
276
|
+
}
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readSessionMode(sessionId) {
|
|
282
|
+
const filePath = sessionStatePath(sessionId);
|
|
283
|
+
if (!filePath) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
289
|
+
if (state.version !== SESSION_STATE_VERSION || !VALID_MODES.has(state.mode)) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return state.mode;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function writeSessionMode(sessionId, mode) {
|
|
299
|
+
const filePath = sessionStatePath(sessionId);
|
|
300
|
+
if (!filePath) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
writeJsonAtomic(filePath, {
|
|
305
|
+
version: SESSION_STATE_VERSION,
|
|
306
|
+
mode,
|
|
307
|
+
updatedAt: new Date().toISOString(),
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function clearSessionMode(sessionId) {
|
|
313
|
+
const filePath = sessionStatePath(sessionId);
|
|
314
|
+
if (!filePath) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
fs.unlinkSync(filePath);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error?.code !== "ENOENT") {
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function stageClearTransfer(input) {
|
|
329
|
+
if (hostName() !== "claude" || input.reason !== "clear") {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const root = findWorkspaceRoot(input.cwd || process.cwd());
|
|
334
|
+
const mode = readSessionMode(input.session_id);
|
|
335
|
+
const filePath = clearTransferPath(root);
|
|
336
|
+
if (!filePath) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!mode) {
|
|
341
|
+
try {
|
|
342
|
+
fs.unlinkSync(filePath);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error?.code !== "ENOENT") {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
writeJsonAtomic(filePath, {
|
|
352
|
+
version: SESSION_STATE_VERSION,
|
|
353
|
+
mode,
|
|
354
|
+
sourceSessionKey: crypto.createHash("sha256").update(String(input.session_id)).digest("hex"),
|
|
355
|
+
createdAt: new Date().toISOString(),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function consumeClearTransfer(input, root) {
|
|
360
|
+
if (hostName() !== "claude" || input.source !== "clear") {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const filePath = clearTransferPath(root);
|
|
365
|
+
if (!filePath) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const claimedPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.claimed`;
|
|
370
|
+
try {
|
|
371
|
+
fs.renameSync(filePath, claimedPath);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error?.code === "ENOENT") {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let state;
|
|
380
|
+
try {
|
|
381
|
+
state = JSON.parse(fs.readFileSync(claimedPath, "utf8"));
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
} finally {
|
|
385
|
+
try {
|
|
386
|
+
fs.unlinkSync(claimedPath);
|
|
387
|
+
} catch {
|
|
388
|
+
// The atomic claim already prevents reuse; cleanup is best-effort.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!isRecord(state)) {
|
|
393
|
+
return null;
|
|
240
394
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
395
|
+
const createdAt = Date.parse(state.createdAt);
|
|
396
|
+
if (
|
|
397
|
+
state.version !== SESSION_STATE_VERSION ||
|
|
398
|
+
!VALID_MODES.has(state.mode) ||
|
|
399
|
+
!Number.isFinite(createdAt) ||
|
|
400
|
+
Date.now() - createdAt > CLEAR_TRANSFER_MAX_AGE_MS ||
|
|
401
|
+
Date.now() < createdAt
|
|
402
|
+
) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return writeSessionMode(input.session_id, state.mode) ? state.mode : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function cleanupExpiredSessionModes() {
|
|
410
|
+
const root = pluginDataRoot();
|
|
411
|
+
if (!root) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const stateRoot = path.join(root, "session-modes");
|
|
416
|
+
const cutoff = Date.now() - SESSION_STATE_MAX_AGE_MS;
|
|
417
|
+
for (const host of ["claude", "codex", "claude-clear"]) {
|
|
418
|
+
const directory = path.join(stateRoot, host);
|
|
419
|
+
let entries = [];
|
|
420
|
+
try {
|
|
421
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
422
|
+
} catch {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const entry of entries) {
|
|
427
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const filePath = path.join(directory, entry.name);
|
|
431
|
+
try {
|
|
432
|
+
if (fs.statSync(filePath).mtimeMs < cutoff) {
|
|
433
|
+
fs.unlinkSync(filePath);
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// Cleanup is best-effort and must not prevent runtime delivery.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sourceLabel(source) {
|
|
443
|
+
if (source === "local") {
|
|
444
|
+
return "personal";
|
|
445
|
+
}
|
|
446
|
+
return source;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function resolveRuntimeState(input) {
|
|
450
|
+
const root = findWorkspaceRoot(input.cwd || process.cwd());
|
|
451
|
+
const config = readConfig(root);
|
|
452
|
+
const restoredMode = config.valid && config.enabled ? consumeClearTransfer(input, root) : null;
|
|
453
|
+
const sessionMode = config.valid && config.enabled ? restoredMode || readSessionMode(input.session_id) : null;
|
|
454
|
+
const resolvedMode = sessionMode || config.defaultMode;
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
root,
|
|
458
|
+
config,
|
|
459
|
+
sessionMode,
|
|
460
|
+
resolvedMode,
|
|
461
|
+
effectiveMode: config.enabled && config.skillsEnabled ? resolvedMode : null,
|
|
462
|
+
modeSource: sessionMode ? "session override" : "configured default",
|
|
463
|
+
};
|
|
244
464
|
}
|
|
245
465
|
|
|
246
466
|
function modeOverlayGuidance(mode) {
|
|
@@ -278,111 +498,179 @@ function modeOverlayGuidance(mode) {
|
|
|
278
498
|
return [];
|
|
279
499
|
}
|
|
280
500
|
|
|
281
|
-
function
|
|
501
|
+
function stateLines(state, sessionChange = null) {
|
|
502
|
+
const { config } = state;
|
|
503
|
+
const lines = [`Configured default: \`${config.defaultMode}\` (${sourceLabel(config.sources.defaultMode)})`];
|
|
504
|
+
|
|
505
|
+
if (sessionChange === "cleared") {
|
|
506
|
+
lines.push("Session override: cleared");
|
|
507
|
+
} else if (state.sessionMode) {
|
|
508
|
+
lines.push(`Session override: \`${state.sessionMode}\``);
|
|
509
|
+
} else {
|
|
510
|
+
lines.push("Session override: none");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (state.effectiveMode) {
|
|
514
|
+
lines.push(`Effective mode: \`${state.effectiveMode}\` (${state.modeSource}, active)`);
|
|
515
|
+
} else {
|
|
516
|
+
lines.push(`Resolved mode: \`${state.resolvedMode}\` (dormant because Skills are disabled)`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return lines;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function capabilityLines(config) {
|
|
282
523
|
return [
|
|
283
|
-
`
|
|
284
|
-
|
|
285
|
-
"Mode behavior:",
|
|
286
|
-
"- `conversation`: answer, discuss, critique, and inspect read-only; switch modes before mutating state.",
|
|
287
|
-
"- `workflow`: use the adaptive workflow for normal consequential work.",
|
|
288
|
-
"- `strict-workflow`: strengthen user-owned decisions, review, and verification for high-risk or hard-to-reverse work.",
|
|
289
|
-
"Do not announce the current mode on every reply. Mention it when the user asks, setup/config is discussed, or the mode changes the next action.",
|
|
290
|
-
...modeOverlayGuidance(mode),
|
|
524
|
+
`Interaction Contract: ${config.interactionContractEnabled ? "enabled" : "disabled"}`,
|
|
525
|
+
`Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`,
|
|
291
526
|
];
|
|
292
527
|
}
|
|
293
528
|
|
|
294
|
-
function
|
|
295
|
-
const { config } =
|
|
529
|
+
function renderSessionStart(state) {
|
|
530
|
+
const { config } = state;
|
|
296
531
|
if (!config.valid) {
|
|
297
|
-
return "
|
|
532
|
+
return "";
|
|
298
533
|
}
|
|
299
534
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
535
|
+
if (!config.enabled) {
|
|
536
|
+
return [
|
|
537
|
+
"# Freeflow Disabled",
|
|
538
|
+
"",
|
|
539
|
+
`Configured but inactive: \`enabled\` is false (${sourceLabel(config.sources.enabled)}).`,
|
|
540
|
+
"No Freeflow interaction, skill, or mode guidance is effective.",
|
|
541
|
+
].join("\n");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const { interactionContract, workflowSkill } = loadRuntimeContext({
|
|
545
|
+
interactionContract: config.interactionContractEnabled,
|
|
546
|
+
skills: config.skillsEnabled,
|
|
547
|
+
});
|
|
548
|
+
const context = [
|
|
549
|
+
"# Freeflow Runtime Context",
|
|
550
|
+
"",
|
|
305
551
|
"Runtime delivery: confirmed for this lifecycle-hook invocation.",
|
|
306
|
-
|
|
307
|
-
|
|
552
|
+
"This context guides behavior only; user instructions, repository instructions, and host safety or approval policy retain precedence.",
|
|
553
|
+
"",
|
|
554
|
+
"## Effective State",
|
|
555
|
+
"",
|
|
556
|
+
...stateLines(state),
|
|
557
|
+
...capabilityLines(config),
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
if (config.interactionContractEnabled) {
|
|
561
|
+
context.push("", interactionContract.trim());
|
|
562
|
+
}
|
|
563
|
+
if (config.skillsEnabled) {
|
|
564
|
+
context.push("", "# Freeflow Workflow Bootstrap", "", workflowSkill.trim());
|
|
565
|
+
}
|
|
566
|
+
if (state.effectiveMode) {
|
|
567
|
+
context.push(...modeOverlayGuidance(state.effectiveMode));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return context.join("\n");
|
|
308
571
|
}
|
|
309
572
|
|
|
310
|
-
function
|
|
311
|
-
|
|
312
|
-
|
|
573
|
+
function normalizePrompt(prompt) {
|
|
574
|
+
return prompt
|
|
575
|
+
.trim()
|
|
576
|
+
.replace(/[.!]+$/u, "")
|
|
577
|
+
.replace(/\s+/gu, " ");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseModeControl(prompt) {
|
|
581
|
+
if (typeof prompt !== "string" || prompt.length > 300 || /\?\s*$/u.test(prompt.trim())) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const normalized = normalizePrompt(prompt);
|
|
586
|
+
const nativeSet = normalized.match(
|
|
587
|
+
/^(?:\/freeflow mode|\$mode-contract|\/(?:freeflow:)?mode-contract) (conversation|workflow|strict-workflow)$/iu,
|
|
588
|
+
);
|
|
589
|
+
if (nativeSet) {
|
|
590
|
+
return { action: "set", mode: nativeSet[1].toLowerCase() };
|
|
591
|
+
}
|
|
592
|
+
if (/^(?:\/freeflow mode|\$mode-contract|\/(?:freeflow:)?mode-contract) reset$/iu.test(normalized)) {
|
|
593
|
+
return { action: "reset" };
|
|
313
594
|
}
|
|
314
595
|
|
|
315
|
-
|
|
596
|
+
const setPatterns = [
|
|
597
|
+
/^switch to (conversation|workflow|strict-workflow)(?: mode)?$/iu,
|
|
598
|
+
/^switch (?:this|the) session to (conversation|workflow|strict-workflow)(?: mode)?$/iu,
|
|
599
|
+
/^use (conversation|workflow|strict-workflow)(?: mode)? for (?:this|the) session$/iu,
|
|
600
|
+
/^(?:change|set) (?:the )?(?:current|active|session) mode to (conversation|workflow|strict-workflow)$/iu,
|
|
601
|
+
/^switch (?:the )?(?:current|active|session) mode to (conversation|workflow|strict-workflow)$/iu,
|
|
602
|
+
];
|
|
603
|
+
for (const pattern of setPatterns) {
|
|
604
|
+
const match = normalized.match(pattern);
|
|
605
|
+
if (match) {
|
|
606
|
+
return { action: "set", mode: match[1].toLowerCase() };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (/^(?:reset|clear|remove) (?:the )?(?:current |active )?session mode(?: override)?$/iu.test(normalized)) {
|
|
611
|
+
return { action: "reset" };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return null;
|
|
316
615
|
}
|
|
317
616
|
|
|
318
|
-
function
|
|
319
|
-
|
|
320
|
-
"
|
|
617
|
+
function renderPromptModeChange(state, sessionChange) {
|
|
618
|
+
const lines = [
|
|
619
|
+
"# Freeflow Mode Update",
|
|
321
620
|
"",
|
|
322
|
-
|
|
323
|
-
`- Skills: ${config.skillsEnabled ? "enabled" : "disabled"}.`,
|
|
324
|
-
`- Output router: ${config.outputRouterEnabled ? "enabled" : "disabled"}.`,
|
|
621
|
+
...stateLines(state, sessionChange),
|
|
325
622
|
"",
|
|
326
|
-
"
|
|
623
|
+
"This user-operated session control changed host-managed state before this model request. It changes workflow behavior, not mutation authority, host permissions, or accepted scope.",
|
|
624
|
+
"Acknowledge the effective mode briefly. Do not claim that configured defaults changed.",
|
|
327
625
|
];
|
|
626
|
+
if (state.effectiveMode) {
|
|
627
|
+
lines.push(...modeOverlayGuidance(state.effectiveMode));
|
|
628
|
+
}
|
|
629
|
+
return lines.join("\n");
|
|
328
630
|
}
|
|
329
631
|
|
|
330
|
-
function
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (!setup.config.valid) {
|
|
632
|
+
function handleUserPromptSubmit(input) {
|
|
633
|
+
const control = parseModeControl(input.prompt);
|
|
634
|
+
if (!control) {
|
|
335
635
|
return "";
|
|
336
636
|
}
|
|
337
637
|
|
|
338
|
-
|
|
638
|
+
const before = resolveRuntimeState(input);
|
|
639
|
+
if (!before.config.valid || !before.config.enabled) {
|
|
640
|
+
return "";
|
|
641
|
+
}
|
|
642
|
+
if (!before.config.skillsEnabled) {
|
|
339
643
|
return [
|
|
340
|
-
"# Freeflow
|
|
644
|
+
"# Freeflow Mode Update Unavailable",
|
|
341
645
|
"",
|
|
342
|
-
`
|
|
343
|
-
"Do not
|
|
344
|
-
"Re-enable only if the user asks; use /freeflow settings for a personal override or /freeflow settings repo for the shared repository default in Pi.",
|
|
646
|
+
`Resolved mode remains \`${before.resolvedMode}\`, but no Freeflow mode is effective because Skills are disabled.`,
|
|
647
|
+
"Do not claim that the session mode changed.",
|
|
345
648
|
].join("\n");
|
|
346
649
|
}
|
|
347
650
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
? ["", "# Freeflow Workflow Bootstrap", "", workflowSkill.trim()]
|
|
356
|
-
: [];
|
|
357
|
-
const outputRouterSection = setup.config.outputRouterEnabled
|
|
358
|
-
? ["", "## Loaded Output Router Skill", "```md", outputRouterSkill.trim(), "```"]
|
|
359
|
-
: [];
|
|
360
|
-
return [
|
|
361
|
-
"# Freeflow Runtime Context",
|
|
362
|
-
"",
|
|
363
|
-
"Freeflow plugin lifecycle hook loaded this at session start.",
|
|
364
|
-
"These instructions are context-loading only. They do not override user instructions, repo instructions, or host safety and approval policy.",
|
|
365
|
-
"",
|
|
366
|
-
"## Repo Setup",
|
|
367
|
-
buildSetupStatus(root),
|
|
368
|
-
"",
|
|
369
|
-
...capabilityStatus(setup.config),
|
|
370
|
-
...interactionSection,
|
|
371
|
-
...skillSections,
|
|
372
|
-
...outputRouterSection,
|
|
373
|
-
].join("\n");
|
|
374
|
-
}
|
|
651
|
+
if (control.action === "set") {
|
|
652
|
+
if (!writeSessionMode(input.session_id, control.mode)) {
|
|
653
|
+
return "Freeflow could not establish a session mode override because this host invocation supplied no writable plugin data or session identifier. Do not claim that the mode changed.";
|
|
654
|
+
}
|
|
655
|
+
const state = resolveRuntimeState(input);
|
|
656
|
+
return renderPromptModeChange(state, "set");
|
|
657
|
+
}
|
|
375
658
|
|
|
376
|
-
|
|
377
|
-
|
|
659
|
+
if (!clearSessionMode(input.session_id)) {
|
|
660
|
+
return "Freeflow could not clear the session mode override because this host invocation supplied no writable plugin data or session identifier. Do not claim that the mode changed.";
|
|
661
|
+
}
|
|
662
|
+
const state = resolveRuntimeState(input);
|
|
663
|
+
return renderPromptModeChange(state, "cleared");
|
|
378
664
|
}
|
|
379
665
|
|
|
380
|
-
function
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
return;
|
|
666
|
+
function shouldHandle(eventName) {
|
|
667
|
+
if (process.env.FREEFLOW_DISABLE_RUNTIME_CONTEXT === "1") {
|
|
668
|
+
return false;
|
|
384
669
|
}
|
|
670
|
+
return eventName === "SessionStart" || eventName === "UserPromptSubmit" || eventName === "SessionEnd";
|
|
671
|
+
}
|
|
385
672
|
|
|
673
|
+
function emitAdditionalContext(eventName, additionalContext) {
|
|
386
674
|
process.stdout.write(
|
|
387
675
|
`${JSON.stringify({
|
|
388
676
|
hookSpecificOutput: {
|
|
@@ -397,19 +685,21 @@ function main() {
|
|
|
397
685
|
const { input, raw } = readStdinJson();
|
|
398
686
|
const eventName = process.argv[2] || input.hook_event_name || "";
|
|
399
687
|
|
|
400
|
-
if (!raw && !eventName) {
|
|
688
|
+
if ((!raw && !eventName) || !shouldHandle(eventName)) {
|
|
401
689
|
return;
|
|
402
690
|
}
|
|
403
691
|
|
|
404
|
-
|
|
692
|
+
cleanupExpiredSessionModes();
|
|
693
|
+
if (eventName === "SessionEnd") {
|
|
694
|
+
stageClearTransfer(input);
|
|
405
695
|
return;
|
|
406
696
|
}
|
|
407
|
-
|
|
408
|
-
|
|
697
|
+
const additionalContext =
|
|
698
|
+
eventName === "SessionStart" ? renderSessionStart(resolveRuntimeState(input)) : handleUserPromptSubmit(input);
|
|
409
699
|
if (!additionalContext.trim()) {
|
|
410
700
|
return;
|
|
411
701
|
}
|
|
412
|
-
emitAdditionalContext(eventName,
|
|
702
|
+
emitAdditionalContext(eventName, additionalContext);
|
|
413
703
|
}
|
|
414
704
|
|
|
415
705
|
try {
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Load Freeflow runtime context
|
|
2
|
+
"description": "Load Freeflow runtime context and manage session mode.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -13,6 +13,31 @@
|
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
16
|
+
],
|
|
17
|
+
"UserPromptSubmit": [
|
|
18
|
+
{
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}/hooks/freeflow-runtime-context.mjs\" UserPromptSubmit",
|
|
23
|
+
"timeout": 5,
|
|
24
|
+
"statusMessage": "Checking Freeflow mode"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"SessionEnd": [
|
|
30
|
+
{
|
|
31
|
+
"matcher": "clear",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}/hooks/freeflow-runtime-context.mjs\" SessionEnd",
|
|
36
|
+
"timeout": 3,
|
|
37
|
+
"statusMessage": "Preserving Freeflow mode"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
16
41
|
]
|
|
17
42
|
}
|
|
18
43
|
}
|