owflow 0.6.0 → 0.7.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 -21
- package/README.md +448 -283
- package/dist/agents/bottleneck-analyzer.md +332 -333
- package/dist/agents/code-quality-pragmatist.md +353 -354
- package/dist/agents/code-reviewer.md +238 -239
- package/dist/agents/codebase-analysis-reporter.md +245 -246
- package/dist/agents/docs-operator.md +22 -23
- package/dist/agents/e2e-test-verifier.md +447 -448
- package/dist/agents/gap-analyzer.md +501 -502
- package/dist/agents/implementation-completeness-checker.md +219 -220
- package/dist/agents/implementation-planner.md +308 -309
- package/dist/agents/information-gatherer.md +690 -691
- package/dist/agents/production-readiness-checker.md +278 -279
- package/dist/agents/project-analyzer.md +403 -403
- package/dist/agents/reality-assessor.md +365 -366
- package/dist/agents/research-planner.md +449 -450
- package/dist/agents/research-synthesizer.md +402 -403
- package/dist/agents/solution-brainstormer.md +256 -257
- package/dist/agents/solution-designer.md +371 -372
- package/dist/agents/spec-auditor.md +297 -298
- package/dist/agents/specification-creator.md +333 -334
- package/dist/agents/task-classifier.md +449 -450
- package/dist/agents/task-group-implementer.md +283 -284
- package/dist/agents/test-suite-runner.md +194 -195
- package/dist/agents/user-docs-generator.md +508 -509
- package/dist/commands/reviews-code.md +90 -90
- package/dist/commands/reviews-pragmatic.md +99 -99
- package/dist/commands/reviews-production-readiness.md +113 -113
- package/dist/commands/reviews-reality-check.md +110 -110
- package/dist/commands/reviews-spec-audit.md +114 -114
- package/dist/commands/work.md +280 -280
- package/dist/configuration/agents-config.d.ts +2 -0
- package/dist/configuration/agents-config.js +8 -44
- package/dist/configuration/commands-config.d.ts +23 -0
- package/dist/configuration/commands-config.js +17 -17
- package/dist/configuration/skills-config.d.ts +2 -0
- package/dist/configuration/skills-config.js +3 -3
- package/dist/hooks/hooks-diagnostics.d.ts +3 -0
- package/dist/hooks/hooks-diagnostics.js +21 -0
- package/dist/hooks/hooks-persistence.d.ts +17 -0
- package/dist/hooks/hooks-persistence.js +207 -0
- package/dist/hooks/hooks-registry.d.ts +6 -0
- package/dist/hooks/hooks-registry.js +104 -0
- package/dist/hooks/hooks-wrapper.d.ts +25 -0
- package/dist/hooks/hooks-wrapper.js +318 -0
- package/dist/hooks/internal/before-tool.d.ts +6 -0
- package/dist/hooks/internal/before-tool.js +13 -0
- package/dist/hooks/internal/session-compaction.d.ts +9 -0
- package/dist/hooks/{session-compaction.js → internal/session-compaction.js} +9 -7
- package/dist/hooks/runtime/circuit-breaker.d.ts +23 -0
- package/dist/hooks/runtime/circuit-breaker.js +39 -0
- package/dist/hooks/runtime/failure-policy.d.ts +15 -0
- package/dist/hooks/runtime/failure-policy.js +49 -0
- package/dist/hooks/runtime/process-executor.d.ts +33 -0
- package/dist/hooks/runtime/process-executor.js +147 -0
- package/dist/hooks/runtime/status-recorder.d.ts +27 -0
- package/dist/hooks/runtime/status-recorder.js +111 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +111 -40
- package/dist/skills/agents-md-generator/SKILL.md +223 -223
- package/dist/skills/codebase-analyzer/SKILL.md +162 -162
- package/dist/skills/codebase-analyzer/references/code-analysis.md +63 -63
- package/dist/skills/codebase-analyzer/references/combined.md +31 -31
- package/dist/skills/codebase-analyzer/references/context-discovery.md +63 -63
- package/dist/skills/codebase-analyzer/references/file-discovery.md +51 -51
- package/dist/skills/codebase-analyzer/references/migration-target.md +23 -23
- package/dist/skills/codebase-analyzer/references/pattern-mining.md +22 -22
- package/dist/skills/dev-analyze/SKILL.md +115 -115
- package/dist/skills/dev-bugfix/SKILL.md +452 -452
- package/dist/skills/dev-finalize/SKILL.md +118 -118
- package/dist/skills/dev-implement/SKILL.md +167 -167
- package/dist/skills/dev-plan/SKILL.md +147 -147
- package/dist/skills/dev-spec/SKILL.md +182 -182
- package/dist/skills/dev-tdd-red/SKILL.md +94 -94
- package/dist/skills/dev-verify/SKILL.md +153 -153
- package/dist/skills/development/SKILL.md +260 -260
- package/dist/skills/diagrams-mermaid/SKILL.md +183 -159
- package/dist/skills/diagrams-mermaid/references/reference.md +220 -148
- package/dist/skills/docs-manager/SKILL.md +379 -379
- package/dist/skills/docs-manager/docs/INDEX.md +199 -199
- package/dist/skills/docs-manager/docs/standards/backend/api.md +25 -25
- package/dist/skills/docs-manager/docs/standards/backend/migrations.md +22 -22
- package/dist/skills/docs-manager/docs/standards/backend/models.md +25 -25
- package/dist/skills/docs-manager/docs/standards/backend/queries.md +22 -22
- package/dist/skills/docs-manager/docs/standards/frontend/accessibility.md +25 -25
- package/dist/skills/docs-manager/docs/standards/frontend/components.md +28 -28
- package/dist/skills/docs-manager/docs/standards/frontend/css.md +16 -16
- package/dist/skills/docs-manager/docs/standards/frontend/responsive.md +28 -28
- package/dist/skills/docs-manager/docs/standards/global/coding-style.md +25 -25
- package/dist/skills/docs-manager/docs/standards/global/commenting.md +10 -10
- package/dist/skills/docs-manager/docs/standards/global/conventions.md +31 -31
- package/dist/skills/docs-manager/docs/standards/global/error-handling.md +22 -22
- package/dist/skills/docs-manager/docs/standards/global/minimal-implementation.md +22 -22
- package/dist/skills/docs-manager/docs/standards/global/validation.md +28 -28
- package/dist/skills/docs-manager/docs/standards/testing/test-writing.md +25 -25
- package/dist/skills/docs-manager/references/agents-md-template.md +29 -29
- package/dist/skills/docs-manager/references/index-md-template.md +73 -73
- package/dist/skills/flow-init/SKILL.md +234 -234
- package/dist/skills/flow-init/references/architecture-template.md +45 -45
- package/dist/skills/flow-init/references/roadmap-templates.md +93 -93
- package/dist/skills/flow-init/references/tech-stack-template.md +70 -70
- package/dist/skills/flow-init/references/vision-templates.md +75 -75
- package/dist/skills/goal-development/SKILL.md +96 -96
- package/dist/skills/goal-migration/SKILL.md +114 -114
- package/dist/skills/goal-performance/SKILL.md +126 -126
- package/dist/skills/goal-research/SKILL.md +118 -118
- package/dist/skills/html-renderer/SKILL.md +173 -173
- package/dist/skills/html-renderer/references/index.html +318 -318
- package/dist/skills/html-renderer/references/styles.css +388 -388
- package/dist/skills/html-renderer/references/tokens.json +104 -104
- package/dist/skills/implementation-plan-executor/SKILL.md +378 -378
- package/dist/skills/implementation-verifier/SKILL.md +307 -307
- package/dist/skills/migration/SKILL.md +268 -268
- package/dist/skills/migration-analyze/SKILL.md +126 -126
- package/dist/skills/migration-finalize/SKILL.md +161 -161
- package/dist/skills/migration-fix/SKILL.md +155 -155
- package/dist/skills/migration-implement/SKILL.md +131 -131
- package/dist/skills/migration-plan/SKILL.md +131 -131
- package/dist/skills/migration-spec/SKILL.md +135 -135
- package/dist/skills/migration-spec/references/migration-strategies.md +430 -430
- package/dist/skills/migration-target/SKILL.md +134 -134
- package/dist/skills/migration-target/references/migration-types.md +489 -489
- package/dist/skills/migration-verify/SKILL.md +184 -184
- package/dist/skills/orchestrator-framework/SKILL.md +65 -65
- package/dist/skills/orchestrator-framework/references/command-namespacing.md +7 -7
- package/dist/skills/orchestrator-framework/references/confirm-or-revise-exception.md +20 -20
- package/dist/skills/orchestrator-framework/references/delegation-rules.md +54 -54
- package/dist/skills/orchestrator-framework/references/dispatcher-handoff.md +27 -27
- package/dist/skills/orchestrator-framework/references/fix-loop-contract.md +65 -65
- package/dist/skills/orchestrator-framework/references/gate-contract.md +122 -122
- package/dist/skills/orchestrator-framework/references/orchestrator-creation-checklist.md +51 -51
- package/dist/skills/orchestrator-framework/references/orchestrator-patterns.md +235 -235
- package/dist/skills/performance/SKILL.md +267 -267
- package/dist/skills/performance-analyze/SKILL.md +129 -129
- package/dist/skills/performance-analyze/references/performance-optimization-guide.md +271 -271
- package/dist/skills/performance-finalize/SKILL.md +117 -117
- package/dist/skills/performance-implement/SKILL.md +114 -114
- package/dist/skills/performance-plan/SKILL.md +112 -112
- package/dist/skills/performance-spec/SKILL.md +145 -145
- package/dist/skills/performance-verify/SKILL.md +164 -164
- package/dist/skills/research/SKILL.md +249 -249
- package/dist/skills/research-brainstorm/SKILL.md +120 -120
- package/dist/skills/research-brainstorm/references/brainstorming-techniques.md +84 -84
- package/dist/skills/research-converge/SKILL.md +130 -130
- package/dist/skills/research-design/SKILL.md +146 -146
- package/dist/skills/research-design/references/design-techniques.md +40 -40
- package/dist/skills/research-finalize/SKILL.md +125 -125
- package/dist/skills/research-fork/SKILL.md +190 -190
- package/dist/skills/research-gather/SKILL.md +133 -133
- package/dist/skills/research-plan/SKILL.md +141 -141
- package/dist/skills/research-plan/references/research-methodologies.md +710 -710
- package/dist/skills/research-quick/SKILL.md +139 -139
- package/dist/skills/research-synthesize/SKILL.md +127 -127
- package/dist/skills/rule-reviewer/SKILL.md +334 -334
- package/dist/skills/standards-discover/SKILL.md +260 -260
- package/dist/skills/standards-discover/references/aggregation-strategy.md +76 -76
- package/dist/skills/standards-discover/references/code-pattern-prompt.md +68 -68
- package/dist/skills/standards-discover/references/config-analyzer-prompt.md +67 -67
- package/dist/skills/standards-discover/references/docs-extractor-prompt.md +65 -65
- package/dist/skills/standards-discover/references/external-analyzer-prompt.md +75 -75
- package/dist/skills/standards-update/SKILL.md +207 -207
- package/dist/templates/orchestrator-state-base.yml +17 -17
- package/dist/templates/orchestrator-state-development.yml +95 -95
- package/dist/templates/orchestrator-state-migration.yml +95 -95
- package/dist/templates/orchestrator-state-performance.yml +64 -64
- package/dist/templates/orchestrator-state-research.yml +67 -67
- package/dist/tools/fork_task.d.ts +17 -0
- package/dist/tools/fork_task.js +21 -11
- package/dist/tools/verify_template.d.ts +11 -0
- package/dist/tools/verify_template.js +32 -17
- package/dist/tui/hooks-dialogs.d.ts +20 -0
- package/dist/tui/hooks-dialogs.js +181 -0
- package/dist/tui/hooks-presenter.d.ts +15 -0
- package/dist/tui/hooks-presenter.js +70 -0
- package/dist/tui/hooks-sidebar.d.ts +10 -0
- package/dist/tui/hooks-sidebar.js +104 -0
- package/dist/tui/primitives.d.ts +21 -0
- package/dist/tui/primitives.js +47 -0
- package/dist/tui.d.ts +12 -0
- package/dist/tui.js +129 -0
- package/dist/types/hook-types.d.ts +125 -0
- package/dist/types/hook-types.js +76 -0
- package/dist/types/opencode-types.d.ts +13 -0
- package/dist/utils/file-util.d.ts +2 -0
- package/dist/utils/file-util.js +6 -6
- package/dist/utils/fork-task.d.ts +27 -0
- package/dist/utils/fork-task.js +18 -23
- package/dist/utils/plugin-info.d.ts +3 -0
- package/dist/utils/plugin-info.js +8 -10
- package/dist/utils/workflow-paths.d.ts +17 -0
- package/dist/utils/workflow-paths.js +18 -0
- package/dist/utils/yaml-structure.d.ts +7 -0
- package/dist/utils/yaml-structure.js +7 -11
- package/package.json +68 -48
- package/dist/hooks/before-tool.js +0 -19
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import matter from
|
|
4
|
-
import { loadMarkdownDir } from
|
|
5
|
-
import { PLUGIN_ROOT } from
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { loadMarkdownDir } from '../utils/file-util.js';
|
|
5
|
+
import { PLUGIN_ROOT } from '../utils/plugin-info.js';
|
|
6
6
|
// Keeps the rendered body independent of the line endings of this file's storage.
|
|
7
|
-
const CRLF =
|
|
8
|
-
const REQUIRED_COMMAND_KEYS = [
|
|
7
|
+
const CRLF = '\r\n';
|
|
8
|
+
const REQUIRED_COMMAND_KEYS = ['name', 'description'];
|
|
9
9
|
/**
|
|
10
10
|
* Renders the command body for one skill from its frontmatter.
|
|
11
11
|
* Pure: one frontmatter object in, one string out.
|
|
12
12
|
*/
|
|
13
13
|
export const renderCommandTemplate = (data) => [
|
|
14
14
|
`CRITICAL INSTRUCTION: You MUST invoke the ${data.name} skill immediately as your FIRST action.`,
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
'',
|
|
16
|
+
'Use the Skill tool with these exact parameters:',
|
|
17
17
|
`name: "${data.name}"`,
|
|
18
18
|
`prompt: "$ARGUMENTS"`,
|
|
19
19
|
].join(CRLF);
|
|
@@ -24,24 +24,24 @@ export const renderCommandTemplate = (data) => [
|
|
|
24
24
|
* register silently incomplete.
|
|
25
25
|
*/
|
|
26
26
|
export const readSkillFrontmatter = () => {
|
|
27
|
-
const skillsDir = path.join(PLUGIN_ROOT,
|
|
27
|
+
const skillsDir = path.join(PLUGIN_ROOT, 'skills');
|
|
28
28
|
const skills = [];
|
|
29
29
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
30
30
|
if (!entry.isDirectory())
|
|
31
31
|
continue;
|
|
32
|
-
const skillFile = path.join(skillsDir, entry.name,
|
|
32
|
+
const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
33
33
|
if (!fs.existsSync(skillFile))
|
|
34
34
|
continue;
|
|
35
35
|
skills.push({
|
|
36
36
|
slug: entry.name,
|
|
37
|
-
data: matter(fs.readFileSync(skillFile,
|
|
37
|
+
data: matter(fs.readFileSync(skillFile, 'utf8')).data,
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
return skills;
|
|
41
41
|
};
|
|
42
42
|
export const configureCommands = (config) => {
|
|
43
43
|
config.command = config.command || {};
|
|
44
|
-
for (const { data, content } of loadMarkdownDir(
|
|
44
|
+
for (const { data, content } of loadMarkdownDir('commands')) {
|
|
45
45
|
const name = data.name;
|
|
46
46
|
if (!name)
|
|
47
47
|
continue;
|
|
@@ -54,16 +54,16 @@ export const configureCommands = (config) => {
|
|
|
54
54
|
...(data.agent && { agent: data.agent }),
|
|
55
55
|
...(data.model && { model: data.model }),
|
|
56
56
|
...(data.subtask !== undefined && {
|
|
57
|
-
subtask: data.subtask ===
|
|
57
|
+
subtask: data.subtask === 'true',
|
|
58
58
|
}),
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
for (const { slug, data } of readSkillFrontmatter()) {
|
|
62
|
-
if (data[
|
|
62
|
+
if (data['user-invocable'] !== true)
|
|
63
63
|
continue;
|
|
64
|
-
const missing = REQUIRED_COMMAND_KEYS.filter((key) => typeof data[key] !==
|
|
64
|
+
const missing = REQUIRED_COMMAND_KEYS.filter((key) => typeof data[key] !== 'string' || data[key] === '');
|
|
65
65
|
if (missing.length > 0) {
|
|
66
|
-
throw new Error(`[owflow] Skill '${slug}' is user-invocable but is missing required frontmatter: ${missing.join(
|
|
66
|
+
throw new Error(`[owflow] Skill '${slug}' is user-invocable but is missing required frontmatter: ${missing.join(', ')}`);
|
|
67
67
|
}
|
|
68
68
|
const name = data.name;
|
|
69
69
|
// Don't overwrite commands the user has explicitly configured
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import { PLUGIN_ROOT } from
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { PLUGIN_ROOT } from '../utils/plugin-info.js';
|
|
3
3
|
export const configureSkills = (config) => {
|
|
4
4
|
config.skills = config.skills || {};
|
|
5
5
|
config.skills.paths = config.skills.paths || [];
|
|
6
|
-
const skillsDir = path.join(PLUGIN_ROOT,
|
|
6
|
+
const skillsDir = path.join(PLUGIN_ROOT, 'skills');
|
|
7
7
|
if (!config.skills.paths.includes(skillsDir)) {
|
|
8
8
|
config.skills.paths.push(skillsDir);
|
|
9
9
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const interpreterLabel = (i) => `${i.resolved ?? i.expected} (${i.available ? 'available' : 'missing'})`;
|
|
2
|
+
const buildReportRows = (input) => [
|
|
3
|
+
...(input.bypassed ? [{ path: '(all hooks)', event: '-', interpreter: '-', status: 'hooks bypassed (OWFLOW_HOOKS_DISABLED=1)' }] : []),
|
|
4
|
+
...input.entries.map((e) => ({
|
|
5
|
+
path: e.path,
|
|
6
|
+
event: e.event,
|
|
7
|
+
interpreter: interpreterLabel(e.interpreter),
|
|
8
|
+
status: e.status === 'registered' ? 'registered' : 'will fail closed at fire',
|
|
9
|
+
})),
|
|
10
|
+
...(input.failures ?? []).map((f) => ({
|
|
11
|
+
path: f.path,
|
|
12
|
+
event: '-',
|
|
13
|
+
interpreter: '-',
|
|
14
|
+
status: `invalid: ${f.error}`,
|
|
15
|
+
})),
|
|
16
|
+
];
|
|
17
|
+
export const renderSnapshot = (input) => {
|
|
18
|
+
const invalid = input.failures?.length ?? 0;
|
|
19
|
+
const head = `[owflow] hooks: ${input.entries.length} loaded${invalid > 0 ? `, ${invalid} invalid` : ''}`;
|
|
20
|
+
return [head, ...buildReportRows(input).map((r) => `[owflow] ${r.path} -> ${r.event} ${r.interpreter} - ${r.status}`)];
|
|
21
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HookEventName, type HookLogReadResult, type HookRunLogRecord, type HookStatusReadResult } from '../types/hook-types.js';
|
|
2
|
+
export * from '../types/hook-types.js';
|
|
3
|
+
export declare const STATUS_FILE_ENV = "OWFLOW_HOOKS_STATUS_FILE";
|
|
4
|
+
export declare const LOG_FILE_ENV = "OWFLOW_HOOK_LOGS_FILE";
|
|
5
|
+
export declare const workspaceKey: (workspace: string) => string;
|
|
6
|
+
export declare const getUserTmpDir: () => string;
|
|
7
|
+
export declare const resolveHooksStatusFile: (workspace: string) => string;
|
|
8
|
+
export declare const resolveHooksLogFile: (workspace: string, hookName?: string) => string;
|
|
9
|
+
export declare const resolveHooksLatestSymlink: (workspace: string, targetLogFile?: string) => string;
|
|
10
|
+
export declare const appendWithRotation: (filePath: string, line: string, maxBytes?: number) => void;
|
|
11
|
+
export declare const writeHookCrashLog: (workspace: string, source: string, error: unknown) => void;
|
|
12
|
+
export declare const readHookStatusSnapshot: (file: string) => HookStatusReadResult;
|
|
13
|
+
export declare const MAX_LOG_TAIL_READ_BYTES: number;
|
|
14
|
+
export declare const DEFAULT_MAX_LOG_RECORDS = 500;
|
|
15
|
+
export declare const readHookLogRecords: (file: string, maxRecords?: number) => HookLogReadResult;
|
|
16
|
+
export declare const readSessionLatestLogs: (file: string, sessionID: string) => Map<string, HookRunLogRecord>;
|
|
17
|
+
export declare const readSessionHookLogs: (file: string, event: HookEventName, since?: number, sessionID?: string) => readonly HookRunLogRecord[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {} from '../types/hook-types.js';
|
|
6
|
+
export * from '../types/hook-types.js';
|
|
7
|
+
export const STATUS_FILE_ENV = 'OWFLOW_HOOKS_STATUS_FILE';
|
|
8
|
+
export const LOG_FILE_ENV = 'OWFLOW_HOOK_LOGS_FILE';
|
|
9
|
+
const DEFAULT_LOG_ROTATION_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
export const workspaceKey = (workspace) => {
|
|
11
|
+
let resolved = path.resolve(workspace);
|
|
12
|
+
try {
|
|
13
|
+
resolved = fs.realpathSync.native(resolved);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// keep resolved path when workspace does not exist yet
|
|
17
|
+
}
|
|
18
|
+
return crypto
|
|
19
|
+
.createHash('sha256')
|
|
20
|
+
.update(process.platform === 'win32' ? resolved.toLowerCase() : resolved)
|
|
21
|
+
.digest('hex')
|
|
22
|
+
.slice(0, 16);
|
|
23
|
+
};
|
|
24
|
+
export const getUserTmpDir = () => {
|
|
25
|
+
const uid = typeof process.getuid === 'function' ? String(process.getuid()) : (process.env.USER ?? process.env.USERNAME ?? 'default');
|
|
26
|
+
return path.join(os.tmpdir(), `opencode-${uid}`);
|
|
27
|
+
};
|
|
28
|
+
export const resolveHooksStatusFile = (workspace) => process.env[STATUS_FILE_ENV] ?? path.join(getUserTmpDir(), `owflow-hooks-${workspaceKey(workspace)}.json`);
|
|
29
|
+
const sanitizeHookStem = (hookName) => {
|
|
30
|
+
if (!hookName)
|
|
31
|
+
return '';
|
|
32
|
+
const cleaned = path
|
|
33
|
+
.basename(hookName)
|
|
34
|
+
.replace(/[^a-z0-9-]/gi, '-')
|
|
35
|
+
.replace(/^-+|-+$/g, '');
|
|
36
|
+
return cleaned.length > 0 ? `-${cleaned}` : '';
|
|
37
|
+
};
|
|
38
|
+
export const resolveHooksLogFile = (workspace, hookName) => process.env[LOG_FILE_ENV] ?? path.join(getUserTmpDir(), `owflow-hook-logs-${workspaceKey(workspace)}${sanitizeHookStem(hookName)}.jsonl`);
|
|
39
|
+
export const resolveHooksLatestSymlink = (workspace, targetLogFile) => {
|
|
40
|
+
const symlinkPath = path.join(getUserTmpDir(), `owflow-hooks-latest-${workspaceKey(workspace)}.jsonl`);
|
|
41
|
+
const target = targetLogFile ?? resolveHooksLogFile(workspace);
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(path.dirname(symlinkPath), { recursive: true, mode: 0o700 });
|
|
44
|
+
try {
|
|
45
|
+
const stat = fs.lstatSync(symlinkPath);
|
|
46
|
+
if (stat.isSymbolicLink() || stat.isFile()) {
|
|
47
|
+
fs.unlinkSync(symlinkPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
fs.symlinkSync(target, symlinkPath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Graceful fallback for non-admin Windows or filesystem restrictions
|
|
55
|
+
}
|
|
56
|
+
return symlinkPath;
|
|
57
|
+
};
|
|
58
|
+
export const appendWithRotation = (filePath, line, maxBytes = DEFAULT_LOG_ROTATION_BYTES) => {
|
|
59
|
+
if (!line)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
63
|
+
try {
|
|
64
|
+
const stat = fs.lstatSync(filePath);
|
|
65
|
+
if (stat.isSymbolicLink())
|
|
66
|
+
return;
|
|
67
|
+
if (stat.size > maxBytes) {
|
|
68
|
+
try {
|
|
69
|
+
const rotStat = fs.lstatSync(`${filePath}.1`);
|
|
70
|
+
if (rotStat.isSymbolicLink())
|
|
71
|
+
fs.unlinkSync(`${filePath}.1`);
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
fs.renameSync(filePath, `${filePath}.1`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err?.code !== 'ENOENT')
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const content = line.endsWith('\n') ? line : `${line}\n`;
|
|
82
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0);
|
|
83
|
+
const fd = fs.openSync(filePath, flags, 0o600);
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Best-effort log persistence never affects hook execution.
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
export const writeHookCrashLog = (workspace, source, error) => {
|
|
96
|
+
try {
|
|
97
|
+
const crashFile = path.join(getUserTmpDir(), 'owflow-hooks-crash.log');
|
|
98
|
+
const timestamp = new Date().toISOString();
|
|
99
|
+
const errStr = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
100
|
+
const entry = `[${timestamp}] [${source}] [workspace: ${workspace}]\n${errStr}\n\n`;
|
|
101
|
+
appendWithRotation(crashFile, entry);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Best-effort crash logging never throws
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const statusFileCache = new Map();
|
|
108
|
+
const logRecordsCache = new Map();
|
|
109
|
+
export const readHookStatusSnapshot = (file) => {
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.lstatSync(file);
|
|
112
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
113
|
+
return { kind: 'invalid' };
|
|
114
|
+
const cached = statusFileCache.get(file);
|
|
115
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
116
|
+
return cached.value;
|
|
117
|
+
}
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
119
|
+
if (parsed?.version !== 1 || typeof parsed.hooks !== 'object' || parsed.hooks === null) {
|
|
120
|
+
return { kind: 'invalid' };
|
|
121
|
+
}
|
|
122
|
+
const result = { kind: 'ok', snapshot: parsed };
|
|
123
|
+
statusFileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value: result });
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
statusFileCache.delete(file);
|
|
128
|
+
return error?.code === 'ENOENT' ? { kind: 'missing' } : { kind: 'invalid' };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
export const MAX_LOG_TAIL_READ_BYTES = 256 * 1024;
|
|
132
|
+
export const DEFAULT_MAX_LOG_RECORDS = 500;
|
|
133
|
+
export const readHookLogRecords = (file, maxRecords = DEFAULT_MAX_LOG_RECORDS) => {
|
|
134
|
+
try {
|
|
135
|
+
const stat = fs.lstatSync(file);
|
|
136
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
137
|
+
return { kind: 'invalid' };
|
|
138
|
+
const cached = logRecordsCache.get(file);
|
|
139
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
140
|
+
return cached.value;
|
|
141
|
+
}
|
|
142
|
+
const startOffset = Math.max(0, stat.size - MAX_LOG_TAIL_READ_BYTES);
|
|
143
|
+
const readLength = stat.size - startOffset;
|
|
144
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
145
|
+
const buffer = Buffer.alloc(readLength);
|
|
146
|
+
try {
|
|
147
|
+
fs.readSync(fd, buffer, 0, readLength, startOffset);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
fs.closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
const raw = buffer.toString('utf8');
|
|
153
|
+
const lines = raw.split('\n');
|
|
154
|
+
if (startOffset > 0)
|
|
155
|
+
lines.shift();
|
|
156
|
+
const records = [];
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
if (!line.trim())
|
|
159
|
+
continue;
|
|
160
|
+
try {
|
|
161
|
+
const rec = JSON.parse(line);
|
|
162
|
+
if (rec?.version === 1 && typeof rec.event === 'string' && typeof rec.name === 'string') {
|
|
163
|
+
records.push(rec);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// skip malformed lines
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const trimmed = records.slice(-maxRecords);
|
|
171
|
+
const result = { kind: 'ok', records: trimmed, truncated: startOffset > 0 };
|
|
172
|
+
logRecordsCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value: result });
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
logRecordsCache.delete(file);
|
|
177
|
+
return error?.code === 'ENOENT' ? { kind: 'missing' } : { kind: 'invalid' };
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
export const readSessionLatestLogs = (file, sessionID) => {
|
|
181
|
+
const map = new Map();
|
|
182
|
+
if (!sessionID)
|
|
183
|
+
return map;
|
|
184
|
+
const result = readHookLogRecords(file);
|
|
185
|
+
if (result.kind !== 'ok')
|
|
186
|
+
return map;
|
|
187
|
+
for (const record of result.records) {
|
|
188
|
+
if (record.sessionID === sessionID) {
|
|
189
|
+
map.set(record.event, record);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
};
|
|
194
|
+
export const readSessionHookLogs = (file, event, since = Number.NEGATIVE_INFINITY, sessionID) => {
|
|
195
|
+
const result = readHookLogRecords(file);
|
|
196
|
+
if (result.kind !== 'ok')
|
|
197
|
+
return [];
|
|
198
|
+
return result.records.filter((r) => {
|
|
199
|
+
if (r.event !== event)
|
|
200
|
+
return false;
|
|
201
|
+
if (sessionID !== undefined && sessionID.length > 0) {
|
|
202
|
+
if (r.sessionID !== sessionID && r.sessionID !== undefined)
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return r.startedAt >= since;
|
|
206
|
+
});
|
|
207
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { HookDigestMode, type HookScan } from '../types/hook-types.js';
|
|
2
|
+
export declare const IGNORED_FILENAMES: Set<string>;
|
|
3
|
+
export declare const scanHooks: (hooksDir: string, options?: {
|
|
4
|
+
mode?: HookDigestMode;
|
|
5
|
+
which?: (cmd: string) => string | null;
|
|
6
|
+
}) => HookScan;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { EXCLUDED_EVENT_NAMES, HookDigestMode, HOOK_EVENT_NAMES, } from '../types/hook-types.js';
|
|
4
|
+
import { hooksLocationLabel } from '../utils/workflow-paths.js';
|
|
5
|
+
const HOOK_STEM_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const STEM_TO_EVENT = new Map(HOOK_EVENT_NAMES.map((ev) => [ev.replaceAll('.', '-'), ev]));
|
|
7
|
+
const EXCLUDED_STEMS = new Map(EXCLUDED_EVENT_NAMES.map((ev) => [ev.replaceAll('.', '-'), ev]));
|
|
8
|
+
export const IGNORED_FILENAMES = new Set(['.ds_store', 'thumbs.db', 'desktop.ini', '.gitkeep']);
|
|
9
|
+
const isIgnoredFile = (name) => {
|
|
10
|
+
const lower = name.toLowerCase();
|
|
11
|
+
return IGNORED_FILENAMES.has(lower) || lower.endsWith('.md') || name.endsWith('~') || name.endsWith('.swp');
|
|
12
|
+
};
|
|
13
|
+
const RECOVERY_HINT = 'Hint: fix or remove the invalid file(s) and restart OpenCode. User hooks are bypassed until the hooks directory validates; the built-in destructive-command guard remains active. To start without user hooks, set OWFLOW_HOOKS_DISABLED=1.';
|
|
14
|
+
export const scanHooks = (hooksDir, options = {}) => {
|
|
15
|
+
const mode = options.mode ?? HookDigestMode.FailFast;
|
|
16
|
+
const which = options.which ?? Bun.which;
|
|
17
|
+
if (!fs.existsSync(hooksDir))
|
|
18
|
+
return { entries: [], failures: [] };
|
|
19
|
+
if (!fs.statSync(hooksDir).isDirectory()) {
|
|
20
|
+
const failures = [{ path: hooksDir, error: `the hooks anchor exists but is not a directory (${hooksLocationLabel})` }];
|
|
21
|
+
if (mode === HookDigestMode.FailFast)
|
|
22
|
+
throw new Error(formatFatal(failures));
|
|
23
|
+
return { entries: [], failures };
|
|
24
|
+
}
|
|
25
|
+
const sh = which('bash') ?? undefined;
|
|
26
|
+
const ps1 = which('pwsh') ?? which('powershell.exe') ?? undefined;
|
|
27
|
+
const interpreters = {
|
|
28
|
+
'.sh': { expected: 'bash', available: Boolean(sh), ...(sh ? { resolved: sh } : {}) },
|
|
29
|
+
'.ps1': { expected: 'pwsh', available: Boolean(ps1), ...(ps1 ? { resolved: ps1 } : {}) },
|
|
30
|
+
};
|
|
31
|
+
const entries = [];
|
|
32
|
+
const failures = [];
|
|
33
|
+
const seenStems = new Map();
|
|
34
|
+
for (const dirent of fs.readdirSync(hooksDir, { withFileTypes: true })) {
|
|
35
|
+
if (isIgnoredFile(dirent.name))
|
|
36
|
+
continue;
|
|
37
|
+
const entryPath = path.join(hooksDir, dirent.name);
|
|
38
|
+
if (!dirent.isFile()) {
|
|
39
|
+
failures.push({ path: entryPath, error: `not a regular file (only hook scripts belong in ${hooksLocationLabel}/)` });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const ext = path.extname(dirent.name);
|
|
43
|
+
if (ext !== '.sh' && ext !== '.ps1') {
|
|
44
|
+
failures.push({ path: entryPath, error: `extension "${ext || '(none)'}" is not routable (expected .sh or .ps1)` });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const stem = dirent.name.slice(0, -ext.length);
|
|
48
|
+
if (!HOOK_STEM_PATTERN.test(stem)) {
|
|
49
|
+
failures.push({ path: entryPath, error: `stem "${stem}" is not lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const excluded = EXCLUDED_STEMS.get(stem);
|
|
53
|
+
if (excluded) {
|
|
54
|
+
failures.push({ path: entryPath, error: `stem "${stem}" derives to "${excluded}", an excluded event that cannot be hooked` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const event = STEM_TO_EVENT.get(stem);
|
|
58
|
+
if (!event) {
|
|
59
|
+
failures.push({
|
|
60
|
+
path: entryPath,
|
|
61
|
+
error: `stem "${stem}" derives to "${stem.replaceAll('-', '.')}", which is not a dispatchable hook or a known host event`,
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const interpreter = interpreters[ext];
|
|
66
|
+
const entry = {
|
|
67
|
+
path: entryPath,
|
|
68
|
+
event,
|
|
69
|
+
interpreter,
|
|
70
|
+
status: interpreter.available ? 'registered' : 'will-fail-closed',
|
|
71
|
+
};
|
|
72
|
+
const existing = seenStems.get(stem);
|
|
73
|
+
if (existing) {
|
|
74
|
+
const names = `${path.basename(existing.path)}, ${path.basename(entryPath)}`;
|
|
75
|
+
failures.push({
|
|
76
|
+
path: entryPath,
|
|
77
|
+
error: `hook name "${stem}" is provided by multiple files (${names}); define it in exactly one format`,
|
|
78
|
+
});
|
|
79
|
+
const existingIdx = entries.findIndex((e) => e.path === existing.path);
|
|
80
|
+
if (existingIdx !== -1) {
|
|
81
|
+
entries.splice(existingIdx, 1);
|
|
82
|
+
failures.push({
|
|
83
|
+
path: existing.path,
|
|
84
|
+
error: `hook name "${stem}" is provided by multiple files (${names}); define it in exactly one format`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
seenStems.set(stem, entry);
|
|
90
|
+
entries.push(entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
entries.sort((a, b) => (a.event < b.event ? -1 : a.event > b.event ? 1 : 0));
|
|
94
|
+
failures.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
95
|
+
if (mode === HookDigestMode.FailFast && failures.length > 0) {
|
|
96
|
+
throw new Error(formatFatal(failures));
|
|
97
|
+
}
|
|
98
|
+
return { entries, failures };
|
|
99
|
+
};
|
|
100
|
+
const formatFatal = (failures) => {
|
|
101
|
+
const count = failures.length === 1 ? '1 invalid entry' : `${failures.length} invalid entries`;
|
|
102
|
+
const rows = failures.map((f) => ` - ${f.path}: ${f.error}`).join('\n');
|
|
103
|
+
return `[owflow] ${count} in ${hooksLocationLabel}/\n${rows}\n${RECOVERY_HINT}`;
|
|
104
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Hooks } from '@opencode-ai/plugin';
|
|
2
|
+
import { type HookEventName, type HookScan, type HooksWrapperOptions } from '../types/hook-types.js';
|
|
3
|
+
export { CircuitBreakerTracker, type CircuitBreakerEntry } from './runtime/circuit-breaker.js';
|
|
4
|
+
export { SessionAbortController, buildCircuitBreakerError, buildInterpreterUnavailableError, buildSpawnError, buildProcessFailureError, } from './runtime/failure-policy.js';
|
|
5
|
+
export { HookSpawnError, executeHookProcess } from './runtime/process-executor.js';
|
|
6
|
+
export { NON_SESSION_STREAM_CAP_BYTES, StatusRecorder } from './runtime/status-recorder.js';
|
|
7
|
+
declare class HooksWrapper {
|
|
8
|
+
private PASSTHROUGH_EVENTS;
|
|
9
|
+
private IDENTITY_KEYS;
|
|
10
|
+
snapshot: HookScan;
|
|
11
|
+
private statusRecorder;
|
|
12
|
+
private abortController;
|
|
13
|
+
private circuitBreaker;
|
|
14
|
+
private activeProcs;
|
|
15
|
+
private directory;
|
|
16
|
+
private runCounter;
|
|
17
|
+
constructor(directory: string, endSession?: (sessionID: string) => Promise<void> | void, options?: HooksWrapperOptions);
|
|
18
|
+
private sessionIDFrom;
|
|
19
|
+
private buildStdinPayload;
|
|
20
|
+
dispose: () => void;
|
|
21
|
+
dispatch: (event: HookEventName, input?: unknown, output?: Record<string, unknown>) => Promise<void>;
|
|
22
|
+
createDispatchHandlers: () => Hooks;
|
|
23
|
+
}
|
|
24
|
+
export { HooksWrapper };
|
|
25
|
+
export default HooksWrapper;
|