@archmax-ai/harness 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/agent.d.ts +174 -0
- package/dist/agent.js +140 -0
- package/dist/agent.js.map +1 -0
- package/dist/assembly/compose.d.ts +170 -0
- package/dist/assembly/compose.js +391 -0
- package/dist/assembly/compose.js.map +1 -0
- package/dist/assembly/delegation-registry.d.ts +5 -0
- package/dist/assembly/delegation-registry.js +118 -0
- package/dist/assembly/delegation-registry.js.map +1 -0
- package/dist/assembly/index.d.ts +192 -0
- package/dist/assembly/index.js +265 -0
- package/dist/assembly/index.js.map +1 -0
- package/dist/assembly/plain.d.ts +7 -0
- package/dist/assembly/plain.js +41 -0
- package/dist/assembly/plain.js.map +1 -0
- package/dist/authoring-skill/archmax-harness/README.md +76 -0
- package/dist/authoring-skill/archmax-harness/SKILL.md +241 -0
- package/dist/authoring-skill/archmax-harness/references/backend-integration.md +626 -0
- package/dist/authoring-skill/archmax-harness/references/cases.md +41 -0
- package/dist/authoring-skill/archmax-harness/references/diagnostics.md +51 -0
- package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +1070 -0
- package/dist/authoring-skill/archmax-harness/references/hooks-and-scripts.md +82 -0
- package/dist/authoring-skill/archmax-harness/references/wiring.md +101 -0
- package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +1631 -0
- package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +241 -0
- package/dist/behaviour/support.d.ts +232 -0
- package/dist/behaviour/support.js +339 -0
- package/dist/behaviour/support.js.map +1 -0
- package/dist/cli/banner.d.ts +11 -0
- package/dist/cli/banner.js +29 -0
- package/dist/cli/banner.js.map +1 -0
- package/dist/cli/bootstrap.d.ts +33 -0
- package/dist/cli/bootstrap.js +153 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/command.d.ts +39 -0
- package/dist/cli/command.js +97 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/state-flow.d.ts +46 -0
- package/dist/cli/state-flow.js +268 -0
- package/dist/cli/state-flow.js.map +1 -0
- package/dist/cli/style.d.ts +41 -0
- package/dist/cli/style.js +64 -0
- package/dist/cli/style.js.map +1 -0
- package/dist/cli/turn.d.ts +41 -0
- package/dist/cli/turn.js +161 -0
- package/dist/cli/turn.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +426 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/code-description.d.ts +22 -0
- package/dist/core/code-description.js +38 -0
- package/dist/core/code-description.js.map +1 -0
- package/dist/core/deepagents.d.ts +183 -0
- package/dist/core/deepagents.js +128 -0
- package/dist/core/deepagents.js.map +1 -0
- package/dist/core/events.d.ts +350 -0
- package/dist/core/events.js +205 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/frontmatter.d.ts +15 -0
- package/dist/core/frontmatter.js +32 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/listing-redact.d.ts +37 -0
- package/dist/core/listing-redact.js +88 -0
- package/dist/core/listing-redact.js.map +1 -0
- package/dist/core/match.d.ts +26 -0
- package/dist/core/match.js +75 -0
- package/dist/core/match.js.map +1 -0
- package/dist/core/message-readers.d.ts +84 -0
- package/dist/core/message-readers.js +159 -0
- package/dist/core/message-readers.js.map +1 -0
- package/dist/core/messages.d.ts +49 -0
- package/dist/core/messages.js +85 -0
- package/dist/core/messages.js.map +1 -0
- package/dist/core/mount-prompt.d.ts +28 -0
- package/dist/core/mount-prompt.js +55 -0
- package/dist/core/mount-prompt.js.map +1 -0
- package/dist/core/mount-redact.d.ts +28 -0
- package/dist/core/mount-redact.js +32 -0
- package/dist/core/mount-redact.js.map +1 -0
- package/dist/core/mounts.d.ts +88 -0
- package/dist/core/mounts.js +147 -0
- package/dist/core/mounts.js.map +1 -0
- package/dist/core/path-mapping.d.ts +52 -0
- package/dist/core/path-mapping.js +113 -0
- package/dist/core/path-mapping.js.map +1 -0
- package/dist/core/platform-prompt.generated.d.ts +2 -0
- package/dist/core/platform-prompt.generated.js +4 -0
- package/dist/core/platform-prompt.generated.js.map +1 -0
- package/dist/core/prompt.d.ts +42 -0
- package/dist/core/prompt.js +75 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/session-id.d.ts +38 -0
- package/dist/core/session-id.js +61 -0
- package/dist/core/session-id.js.map +1 -0
- package/dist/core/session-store.d.ts +85 -0
- package/dist/core/session-store.js +149 -0
- package/dist/core/session-store.js.map +1 -0
- package/dist/core/session-zone.d.ts +54 -0
- package/dist/core/session-zone.js +82 -0
- package/dist/core/session-zone.js.map +1 -0
- package/dist/core/skill-prompt.d.ts +31 -0
- package/dist/core/skill-prompt.js +54 -0
- package/dist/core/skill-prompt.js.map +1 -0
- package/dist/core/skill-redact.d.ts +15 -0
- package/dist/core/skill-redact.js +27 -0
- package/dist/core/skill-redact.js.map +1 -0
- package/dist/core/skills.d.ts +172 -0
- package/dist/core/skills.js +214 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/tool-mocks.d.ts +23 -0
- package/dist/core/tool-mocks.js +39 -0
- package/dist/core/tool-mocks.js.map +1 -0
- package/dist/core/tool-telemetry.d.ts +16 -0
- package/dist/core/tool-telemetry.js +40 -0
- package/dist/core/tool-telemetry.js.map +1 -0
- package/dist/core/usage.d.ts +114 -0
- package/dist/core/usage.js +226 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/core/workspace-context.d.ts +136 -0
- package/dist/core/workspace-context.js +178 -0
- package/dist/core/workspace-context.js.map +1 -0
- package/dist/core/workspace-prompt.d.ts +7 -0
- package/dist/core/workspace-prompt.js +102 -0
- package/dist/core/workspace-prompt.js.map +1 -0
- package/dist/core/workspace-router.d.ts +61 -0
- package/dist/core/workspace-router.js +145 -0
- package/dist/core/workspace-router.js.map +1 -0
- package/dist/core/workspace.d.ts +85 -0
- package/dist/core/workspace.js +193 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/core/zones.d.ts +181 -0
- package/dist/core/zones.js +202 -0
- package/dist/core/zones.js.map +1 -0
- package/dist/env.d.ts +99 -0
- package/dist/env.js +271 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +265 -0
- package/dist/index.js +197 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/kernel.d.ts +192 -0
- package/dist/kernel/kernel.js +751 -0
- package/dist/kernel/kernel.js.map +1 -0
- package/dist/lifecycle/hook-shape.d.ts +44 -0
- package/dist/lifecycle/hook-shape.js +66 -0
- package/dist/lifecycle/hook-shape.js.map +1 -0
- package/dist/lifecycle/runner.d.ts +177 -0
- package/dist/lifecycle/runner.js +372 -0
- package/dist/lifecycle/runner.js.map +1 -0
- package/dist/lifecycle/transcript.d.ts +27 -0
- package/dist/lifecycle/transcript.js +43 -0
- package/dist/lifecycle/transcript.js.map +1 -0
- package/dist/machine/allow.d.ts +65 -0
- package/dist/machine/allow.js +107 -0
- package/dist/machine/allow.js.map +1 -0
- package/dist/machine/delegation.d.ts +9 -0
- package/dist/machine/delegation.js +10 -0
- package/dist/machine/delegation.js.map +1 -0
- package/dist/machine/diagnostic.d.ts +14 -0
- package/dist/machine/diagnostic.js +2 -0
- package/dist/machine/diagnostic.js.map +1 -0
- package/dist/machine/lint-spec.d.ts +4 -0
- package/dist/machine/lint-spec.js +266 -0
- package/dist/machine/lint-spec.js.map +1 -0
- package/dist/machine/load-spec.d.ts +90 -0
- package/dist/machine/load-spec.js +120 -0
- package/dist/machine/load-spec.js.map +1 -0
- package/dist/machine/machine.d.ts +273 -0
- package/dist/machine/machine.js +551 -0
- package/dist/machine/machine.js.map +1 -0
- package/dist/machine/mount-grants.d.ts +25 -0
- package/dist/machine/mount-grants.js +24 -0
- package/dist/machine/mount-grants.js.map +1 -0
- package/dist/machine/slug.d.ts +14 -0
- package/dist/machine/slug.js +17 -0
- package/dist/machine/slug.js.map +1 -0
- package/dist/machine/spec-schema.d.ts +662 -0
- package/dist/machine/spec-schema.js +717 -0
- package/dist/machine/spec-schema.js.map +1 -0
- package/dist/machine/tool-names.d.ts +98 -0
- package/dist/machine/tool-names.js +144 -0
- package/dist/machine/tool-names.js.map +1 -0
- package/dist/machine/triggers.d.ts +114 -0
- package/dist/machine/triggers.js +165 -0
- package/dist/machine/triggers.js.map +1 -0
- package/dist/machine/types.d.ts +186 -0
- package/dist/machine/types.js +12 -0
- package/dist/machine/types.js.map +1 -0
- package/dist/machine/validate-spec.d.ts +42 -0
- package/dist/machine/validate-spec.js +29 -0
- package/dist/machine/validate-spec.js.map +1 -0
- package/dist/machine/variables.d.ts +196 -0
- package/dist/machine/variables.js +415 -0
- package/dist/machine/variables.js.map +1 -0
- package/dist/public/cli.d.ts +15 -0
- package/dist/public/cli.js +14 -0
- package/dist/public/cli.js.map +1 -0
- package/dist/public/messages.d.ts +33 -0
- package/dist/public/messages.js +33 -0
- package/dist/public/messages.js.map +1 -0
- package/dist/public/sandbox.d.ts +99 -0
- package/dist/public/sandbox.js +32 -0
- package/dist/public/sandbox.js.map +1 -0
- package/dist/public/spec.d.ts +147 -0
- package/dist/public/spec.js +149 -0
- package/dist/public/spec.js.map +1 -0
- package/dist/public/testing.d.ts +71 -0
- package/dist/public/testing.js +39 -0
- package/dist/public/testing.js.map +1 -0
- package/dist/rubrics/rubrics.d.ts +135 -0
- package/dist/rubrics/rubrics.js +145 -0
- package/dist/rubrics/rubrics.js.map +1 -0
- package/dist/runtime/contract.d.ts +73 -0
- package/dist/runtime/contract.js +96 -0
- package/dist/runtime/contract.js.map +1 -0
- package/dist/sandbox/assets/parts/core.js +22 -0
- package/dist/sandbox/assets/parts/lifecycle-hook.js +47 -0
- package/dist/sandbox/bridge.d.ts +16 -0
- package/dist/sandbox/bridge.js +95 -0
- package/dist/sandbox/bridge.js.map +1 -0
- package/dist/sandbox/executor.d.ts +46 -0
- package/dist/sandbox/executor.js +119 -0
- package/dist/sandbox/executor.js.map +1 -0
- package/dist/sandbox/imports.d.ts +33 -0
- package/dist/sandbox/imports.js +67 -0
- package/dist/sandbox/imports.js.map +1 -0
- package/dist/sandbox/prelude.d.ts +12 -0
- package/dist/sandbox/prelude.js +51 -0
- package/dist/sandbox/prelude.js.map +1 -0
- package/dist/sandbox/ptc-gateway.d.ts +104 -0
- package/dist/sandbox/ptc-gateway.js +167 -0
- package/dist/sandbox/ptc-gateway.js.map +1 -0
- package/dist/sandbox/runtime.d.ts +59 -0
- package/dist/sandbox/runtime.js +51 -0
- package/dist/sandbox/runtime.js.map +1 -0
- package/dist/sandbox/tools.d.ts +34 -0
- package/dist/sandbox/tools.js +147 -0
- package/dist/sandbox/tools.js.map +1 -0
- package/dist/sessions/operations.d.ts +79 -0
- package/dist/sessions/operations.js +121 -0
- package/dist/sessions/operations.js.map +1 -0
- package/dist/sessions/resolve.d.ts +97 -0
- package/dist/sessions/resolve.js +145 -0
- package/dist/sessions/resolve.js.map +1 -0
- package/dist/sessions/resume.d.ts +242 -0
- package/dist/sessions/resume.js +288 -0
- package/dist/sessions/resume.js.map +1 -0
- package/dist/sessions/scope.d.ts +99 -0
- package/dist/sessions/scope.js +145 -0
- package/dist/sessions/scope.js.map +1 -0
- package/dist/sessions/summary.d.ts +65 -0
- package/dist/sessions/summary.js +56 -0
- package/dist/sessions/summary.js.map +1 -0
- package/dist/testing/assertions.d.ts +30 -0
- package/dist/testing/assertions.js +231 -0
- package/dist/testing/assertions.js.map +1 -0
- package/dist/testing/case-document.d.ts +141 -0
- package/dist/testing/case-document.js +51 -0
- package/dist/testing/case-document.js.map +1 -0
- package/dist/testing/case-schema.d.ts +29 -0
- package/dist/testing/case-schema.js +519 -0
- package/dist/testing/case-schema.js.map +1 -0
- package/dist/testing/discovery.d.ts +10 -0
- package/dist/testing/discovery.js +24 -0
- package/dist/testing/discovery.js.map +1 -0
- package/dist/testing/driver.d.ts +78 -0
- package/dist/testing/driver.js +117 -0
- package/dist/testing/driver.js.map +1 -0
- package/dist/testing/grade.d.ts +116 -0
- package/dist/testing/grade.js +261 -0
- package/dist/testing/grade.js.map +1 -0
- package/dist/testing/interpreter.d.ts +55 -0
- package/dist/testing/interpreter.js +103 -0
- package/dist/testing/interpreter.js.map +1 -0
- package/dist/testing/mock-middleware.d.ts +5 -0
- package/dist/testing/mock-middleware.js +33 -0
- package/dist/testing/mock-middleware.js.map +1 -0
- package/dist/testing/runner.d.ts +205 -0
- package/dist/testing/runner.js +266 -0
- package/dist/testing/runner.js.map +1 -0
- package/dist/testing/target.d.ts +27 -0
- package/dist/testing/target.js +21 -0
- package/dist/testing/target.js.map +1 -0
- package/dist/validate/validate.d.ts +41 -0
- package/dist/validate/validate.js +669 -0
- package/dist/validate/validate.js.map +1 -0
- package/dist/workflow/agent-tools.d.ts +10 -0
- package/dist/workflow/agent-tools.js +10 -0
- package/dist/workflow/agent-tools.js.map +1 -0
- package/dist/workflow/checkpointer.d.ts +52 -0
- package/dist/workflow/checkpointer.js +200 -0
- package/dist/workflow/checkpointer.js.map +1 -0
- package/dist/workflow/control-tools.d.ts +174 -0
- package/dist/workflow/control-tools.js +468 -0
- package/dist/workflow/control-tools.js.map +1 -0
- package/dist/workflow/governance.d.ts +104 -0
- package/dist/workflow/governance.js +546 -0
- package/dist/workflow/governance.js.map +1 -0
- package/dist/workflow/middleware.d.ts +85 -0
- package/dist/workflow/middleware.js +283 -0
- package/dist/workflow/middleware.js.map +1 -0
- package/dist/workflow/on-error.d.ts +25 -0
- package/dist/workflow/on-error.js +58 -0
- package/dist/workflow/on-error.js.map +1 -0
- package/dist/workflow/parks.d.ts +46 -0
- package/dist/workflow/parks.js +513 -0
- package/dist/workflow/parks.js.map +1 -0
- package/dist/workflow/paths.d.ts +67 -0
- package/dist/workflow/paths.js +108 -0
- package/dist/workflow/paths.js.map +1 -0
- package/dist/workflow/prompt-cache.d.ts +70 -0
- package/dist/workflow/prompt-cache.js +103 -0
- package/dist/workflow/prompt-cache.js.map +1 -0
- package/dist/workflow/prompt-pruning.d.ts +20 -0
- package/dist/workflow/prompt-pruning.js +83 -0
- package/dist/workflow/prompt-pruning.js.map +1 -0
- package/dist/workflow/render-prompt.d.ts +45 -0
- package/dist/workflow/render-prompt.js +140 -0
- package/dist/workflow/render-prompt.js.map +1 -0
- package/dist/workflow/runtime-config.d.ts +7 -0
- package/dist/workflow/runtime-config.js +9 -0
- package/dist/workflow/runtime-config.js.map +1 -0
- package/dist/workflow/session-artifacts.d.ts +90 -0
- package/dist/workflow/session-artifacts.js +134 -0
- package/dist/workflow/session-artifacts.js.map +1 -0
- package/dist/workflow/session-view.d.ts +31 -0
- package/dist/workflow/session-view.js +94 -0
- package/dist/workflow/session-view.js.map +1 -0
- package/dist/workflow/snapshot.d.ts +14 -0
- package/dist/workflow/snapshot.js +97 -0
- package/dist/workflow/snapshot.js.map +1 -0
- package/dist/workflow/state.d.ts +347 -0
- package/dist/workflow/state.js +322 -0
- package/dist/workflow/state.js.map +1 -0
- package/dist/workflow/sub-workflow.d.ts +174 -0
- package/dist/workflow/sub-workflow.js +392 -0
- package/dist/workflow/sub-workflow.js.map +1 -0
- package/dist/workflow/tool-service.d.ts +48 -0
- package/dist/workflow/tool-service.js +291 -0
- package/dist/workflow/tool-service.js.map +1 -0
- package/dist/workflow/turn-boundary.d.ts +39 -0
- package/dist/workflow/turn-boundary.js +128 -0
- package/dist/workflow/turn-boundary.js.map +1 -0
- package/dist/workflow/turn-runner.d.ts +40 -0
- package/dist/workflow/turn-runner.js +122 -0
- package/dist/workflow/turn-runner.js.map +1 -0
- package/dist/workflow/workflow-tools.d.ts +46 -0
- package/dist/workflow/workflow-tools.js +90 -0
- package/dist/workflow/workflow-tools.js.map +1 -0
- package/package.json +119 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static validation of a workflow scaffold — no model calls.
|
|
3
|
+
*
|
|
4
|
+
* Single-document rules are the schema's (`machine/spec-schema.ts`) and the
|
|
5
|
+
* linter's (`machine/lint-spec.ts`), surfaced through the runtime's loader. This
|
|
6
|
+
* module owns what needs more than one document: the workflow slug, runtime
|
|
7
|
+
* contract, hook files, the skill registry, delegation siblings,
|
|
8
|
+
* offline test cases, and kernel probes that ask the real governance pipeline
|
|
9
|
+
* whether a granted entry would be blocked at run time.
|
|
10
|
+
*/
|
|
11
|
+
import { canonicalizeRelPath, normalizeRelPath } from "../core/workspace.js";
|
|
12
|
+
import { isSlug, SLUG_PATTERN } from "../machine/slug.js";
|
|
13
|
+
import { createWorkspaceContext } from "../core/workspace-context.js";
|
|
14
|
+
import { normalizeAllowEntry } from "../machine/allow.js";
|
|
15
|
+
import { normalizeMountGrants, } from "../machine/mount-grants.js";
|
|
16
|
+
import { hookKind, normalizeHooks } from "../lifecycle/hook-shape.js";
|
|
17
|
+
import { BUILTIN_HOOK_KINDS } from "../lifecycle/runner.js";
|
|
18
|
+
import { authoringPlanePrefix, describeAuthoringPrefix } from "../core/mounts.js";
|
|
19
|
+
import { ADVANCE_TOOL, RUN_TOOL, UNGRANTABLE_TOOLS, workflowToolName } from "../machine/tool-names.js";
|
|
20
|
+
import { MANUAL_TRIGGER, triggerBindings } from "../machine/triggers.js";
|
|
21
|
+
import { WorkflowMachine } from "../machine/machine.js";
|
|
22
|
+
import { loadMachineSpec } from "../machine/load-spec.js";
|
|
23
|
+
import { findForeignImports } from "../sandbox/imports.js";
|
|
24
|
+
import { specDisabled, } from "../machine/types.js";
|
|
25
|
+
import { decide } from "../kernel/kernel.js";
|
|
26
|
+
import { classifyWorkspacePath, mountNameOfPattern, SESSION_OPEN_DIR } from "../core/zones.js";
|
|
27
|
+
import { NO_MOUNTS } from "../core/mounts.js";
|
|
28
|
+
import { DEFAULT_SKILL_SOURCES, inspectSkillSources, skillOfPattern, skillPrefixes, } from "../core/skills.js";
|
|
29
|
+
import { DEFAULT_WORKFLOW, resolveHookScript, workflowPaths } from "../workflow/paths.js";
|
|
30
|
+
import { DEFAULT_SUB_WORKFLOW_DEPTH } from "../workflow/sub-workflow.js";
|
|
31
|
+
import { resolveRuntimeContract, unsupportedRuntimeContractMessage, } from "../runtime/contract.js";
|
|
32
|
+
import { CaseSchemaError, normalizeFixturePath, parseCaseDocument, } from "../testing/case-schema.js";
|
|
33
|
+
import { listCaseFiles } from "../testing/discovery.js";
|
|
34
|
+
function finalize(workflow, rootDir, diagnostics) {
|
|
35
|
+
return {
|
|
36
|
+
valid: !diagnostics.some((d) => d.severity === "error"),
|
|
37
|
+
workflow,
|
|
38
|
+
...(rootDir !== undefined ? { rootDir } : {}),
|
|
39
|
+
diagnostics,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Statically validate a workflow scaffold (`workflow.yaml` plus referenced
|
|
44
|
+
* skills, lifecycle scripts, sibling workflows and test cases) by
|
|
45
|
+
* reading it through the same backends the runtime uses. Performs only file
|
|
46
|
+
* reads and parsing — "valid" means the runtime can load the scaffold, not that
|
|
47
|
+
* a run will succeed.
|
|
48
|
+
*/
|
|
49
|
+
export async function validateWorkflow(options = {}) {
|
|
50
|
+
const workflowName = options.workflow ?? DEFAULT_WORKFLOW;
|
|
51
|
+
const paths = workflowPaths(workflowName);
|
|
52
|
+
// Two planes, checked from the right side of each: authoring-plane content
|
|
53
|
+
// (this workflow's spec — its grading rubrics included — its siblings', its
|
|
54
|
+
// hooks and its cases) is read through the authoring workspace, agent-visible
|
|
55
|
+
// content (`skills/`, `AGENTS.md`) through the agent workspace.
|
|
56
|
+
const { rootDir, workspace, authoring, mountPrefixes } = createWorkspaceContext({
|
|
57
|
+
rootDir: options.rootDir,
|
|
58
|
+
...(options.mounts ? { mounts: options.mounts } : {}),
|
|
59
|
+
});
|
|
60
|
+
const diagnostics = [];
|
|
61
|
+
const add = (severity, message, extra = {}) => diagnostics.push({ severity, message, ...extra });
|
|
62
|
+
// The workflow slug (directory name and CLI argument) takes the same
|
|
63
|
+
// kebab-case shape as a state slug; missing or nonconforming is an **error**.
|
|
64
|
+
if (workflowName.trim() === "") {
|
|
65
|
+
add("error", `No workflow slug provided; a workflow is identified by its 'workflows/<slug>/' directory name (e.g. 'order-lookup'), passed as the CLI argument or the 'workflow' option.`, { field: "workflow" });
|
|
66
|
+
}
|
|
67
|
+
else if (!SLUG_PATTERN.test(workflowName)) {
|
|
68
|
+
add("error", `Workflow slug '${workflowName}' is not hyphen-separated kebab-case; rename the 'workflows/${workflowName}/' directory to lowercase segments joined by single hyphens (e.g. 'order-lookup') so it matches the slug convention state slugs follow. Give the workflow a 'title' for its human-readable label.`, { file: paths.workflowYaml, field: "workflow" });
|
|
69
|
+
}
|
|
70
|
+
const { spec, issues, lint, specFile } = await loadMachineSpec(authoring, paths);
|
|
71
|
+
const blocking = issues.find((i) => i.kind === "missing" || i.kind === "not-a-mapping");
|
|
72
|
+
if (blocking || !spec) {
|
|
73
|
+
if (blocking?.kind === "not-a-mapping") {
|
|
74
|
+
add("error", `${specFile} machine spec is not a YAML mapping`, { file: specFile });
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
add("error", `No workflow.yaml found for workflow '${workflowName}'`, {
|
|
78
|
+
file: paths.workflowYaml,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return finalize(workflowName, rootDir, diagnostics);
|
|
82
|
+
}
|
|
83
|
+
// Loader issues (schema errors, lint warnings, a competing WORKFLOW.md) in the
|
|
84
|
+
// loader's own words, so a spec fails here for the same reason it fails to load.
|
|
85
|
+
for (const issue of [...issues, ...lint]) {
|
|
86
|
+
add(issue.severity, issue.message, {
|
|
87
|
+
file: issue.kind === "competing-machines" ? paths.workflow : specFile,
|
|
88
|
+
...(issue.field ? { field: issue.field } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const contract = resolveRuntimeContract(spec.runtime);
|
|
92
|
+
const unsupported = unsupportedRuntimeContractMessage(contract);
|
|
93
|
+
if (unsupported) {
|
|
94
|
+
add("error", unsupported, { file: specFile, field: "runtime" });
|
|
95
|
+
}
|
|
96
|
+
// The schema has already reported a missing or empty `states`; nothing below
|
|
97
|
+
// can run without one.
|
|
98
|
+
const states = spec.states;
|
|
99
|
+
if (!states || typeof states !== "object" || Object.keys(states).length === 0) {
|
|
100
|
+
return finalize(workflowName, rootDir, diagnostics);
|
|
101
|
+
}
|
|
102
|
+
// Build the machine from the parsed spec so static checks decide through the
|
|
103
|
+
// same kernel the runtime uses (validator/runtime parity).
|
|
104
|
+
const machine = WorkflowMachine.fromSpec(spec);
|
|
105
|
+
// Custom hook kinds the workspace declares it will register executors for;
|
|
106
|
+
// the validator accepts these offline (built-in kinds are always accepted).
|
|
107
|
+
const expectedHookKinds = new Set(spec.extensions?.hooks ?? []);
|
|
108
|
+
const skillRegistry = await validateSkills(workspace, machine, spec, specFile, add, options.skills ?? [...DEFAULT_SKILL_SOURCES]);
|
|
109
|
+
validateMounts(machine, spec, specFile, add, mountPrefixes);
|
|
110
|
+
for (const [slug, declared] of Object.entries(states)) {
|
|
111
|
+
// A bare `done:` is YAML null; the schema reads it as an empty state, and so
|
|
112
|
+
// does this pass when the schema itself was what failed.
|
|
113
|
+
const state = (declared ?? {});
|
|
114
|
+
const field = `states.${slug}`;
|
|
115
|
+
validateStateSlug(slug, specFile, add);
|
|
116
|
+
for (const phase of ["before", "after"]) {
|
|
117
|
+
await validateHookField(authoring, workflowName, state[phase], slug, phase, field, add, expectedHookKinds);
|
|
118
|
+
}
|
|
119
|
+
validateStateGovernance(machine, states, slug, state, field, add, mountPrefixes, skillRegistry);
|
|
120
|
+
}
|
|
121
|
+
for (const entry of spec.tools?.allow_always ?? []) {
|
|
122
|
+
const { tool, argMatchers } = normalizeAllowEntry(entry);
|
|
123
|
+
if (!tool)
|
|
124
|
+
continue;
|
|
125
|
+
if (UNGRANTABLE_TOOLS.has(tool)) {
|
|
126
|
+
add("error", ungrantableReason("tools.allow_always", tool), {
|
|
127
|
+
file: specFile,
|
|
128
|
+
field: "tools.allow_always",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
for (const filePath of argMatchers?.file_path ?? []) {
|
|
132
|
+
const zone = classifyWorkspacePath(filePath);
|
|
133
|
+
if (zone === "run-internal" || zone === "run-offload") {
|
|
134
|
+
add("error", `'tools.allow_always' allows '${tool}' on '${filePath}', which targets a runtime-owned run area; the runtime will block it. Write under '${SESSION_OPEN_DIR}/' instead.`, { file: specFile, field: "tools.allow_always" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await validateSubWorkflows(authoring, workflowName, spec, add);
|
|
139
|
+
await validateOfflineTestCases(authoring, paths.testsDir, spec, add);
|
|
140
|
+
return finalize(workflowName, rootDir, diagnostics);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Validate the workflow's declarative offline test cases (`tests/**\/*.test.yaml`)
|
|
144
|
+
* offline: schema violations, trigger ids the workflow does not declare, and
|
|
145
|
+
* dangling or escaping `from:` fixture references. Same rules as the case
|
|
146
|
+
* runner (shared modules), reported as diagnostics instead of thrown.
|
|
147
|
+
*/
|
|
148
|
+
async function validateOfflineTestCases(authoring, testsDir, spec, add) {
|
|
149
|
+
const caseFiles = await listCaseFiles(authoring, testsDir);
|
|
150
|
+
// Every trigger id the machine declares — each one enters a state, so a case
|
|
151
|
+
// naming one that is missing here names nothing.
|
|
152
|
+
const declaredTriggers = new Set(triggerBindings(spec).keys());
|
|
153
|
+
const checkTrigger = (file, trigger, at) => {
|
|
154
|
+
if (!trigger || trigger.id === MANUAL_TRIGGER || declaredTriggers.has(trigger.id))
|
|
155
|
+
return;
|
|
156
|
+
add("error", `'${file}' declares trigger '${trigger.id}', which no state of this workflow declares ` +
|
|
157
|
+
`(declared: ${[...declaredTriggers].sort().join(", ") || `${MANUAL_TRIGGER} only`})`, { file, field: at });
|
|
158
|
+
};
|
|
159
|
+
const docs = new Map();
|
|
160
|
+
for (const file of caseFiles) {
|
|
161
|
+
const source = await authoring.readText(file);
|
|
162
|
+
if (source == null)
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
docs.set(file, parseCaseDocument(file, source, testsDir));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (err instanceof CaseSchemaError) {
|
|
169
|
+
add("error", err.message, { file });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// A `deliver` step's trigger id is deliberately not cross-checked against the
|
|
176
|
+
// workflow: a park awaits no declared ids, so any id is deliverable.
|
|
177
|
+
for (const [file, doc] of docs) {
|
|
178
|
+
checkTrigger(file, doc.trigger, "trigger");
|
|
179
|
+
for (const [path, entry] of Object.entries(doc.workspace)) {
|
|
180
|
+
if (entry.source !== "file")
|
|
181
|
+
continue;
|
|
182
|
+
let fixture;
|
|
183
|
+
try {
|
|
184
|
+
fixture = normalizeFixturePath(entry.from, file);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
add("error", err.message, { file, field: "workspace" });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!(await authoring.exists(`${testsDir}/${fixture}`))) {
|
|
191
|
+
add("error", `'${file}' workspace entry '${path}' references 'from: ${entry.from}', but ` +
|
|
192
|
+
`'${testsDir}/${fixture}' does not exist`, { file, field: "workspace" });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Cross-workflow checks for delegation targets: existence, startability,
|
|
199
|
+
* disabled state, and termination within the dispatcher's depth bound. Reads
|
|
200
|
+
* *sibling* workflows, which is why it is the validator's, not the schema's.
|
|
201
|
+
*/
|
|
202
|
+
async function validateSubWorkflows(authoring, workflowName, spec, add) {
|
|
203
|
+
const machine = WorkflowMachine.fromSpec(spec);
|
|
204
|
+
if (machine.delegationTargets().length === 0)
|
|
205
|
+
return;
|
|
206
|
+
const loadedSpecs = new Map();
|
|
207
|
+
/** A sibling workflow's spec, or `null` when it is missing or unusable. */
|
|
208
|
+
async function specFor(slug) {
|
|
209
|
+
if (loadedSpecs.has(slug))
|
|
210
|
+
return loadedSpecs.get(slug) ?? null;
|
|
211
|
+
const loaded = await loadMachineSpec(authoring, workflowPaths(slug));
|
|
212
|
+
const resolved = loaded.usable && loaded.spec ? loaded.spec : null;
|
|
213
|
+
loadedSpecs.set(slug, resolved);
|
|
214
|
+
return resolved;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Walk the delegation graph depth-first, reporting each problem once. `chain`
|
|
218
|
+
* is the path from the validated workflow down to `current` — both the cycle
|
|
219
|
+
* detector and the depth counter.
|
|
220
|
+
*/
|
|
221
|
+
async function walk(current, chain, field) {
|
|
222
|
+
const currentSpec = current === workflowName ? spec : await specFor(current);
|
|
223
|
+
if (!currentSpec)
|
|
224
|
+
return;
|
|
225
|
+
const currentMachine = WorkflowMachine.fromSpec(currentSpec);
|
|
226
|
+
for (const slug of currentMachine.reachableStates()) {
|
|
227
|
+
for (const target of currentMachine.delegationTargets(slug)) {
|
|
228
|
+
const entry = workflowToolName(target);
|
|
229
|
+
const where = current === workflowName
|
|
230
|
+
? `state '${slug}' allows '${entry}'`
|
|
231
|
+
: `workflow '${current}' (state '${slug}') allows '${entry}'`;
|
|
232
|
+
if (!isSlug(target)) {
|
|
233
|
+
add("error", `${where}, whose target '${target}' is not hyphen-separated kebab-case. A workflow ` +
|
|
234
|
+
`slug is its 'workflows/<slug>/' directory name (e.g. 'enrich-account').`, { field });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (chain.includes(target)) {
|
|
238
|
+
add("error", `${where}, which is already running in this chain ` +
|
|
239
|
+
`(${[...chain, target].join(" → ")}). The delegation would never terminate.`, { field });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const targetSpec = await specFor(target);
|
|
243
|
+
if (!targetSpec) {
|
|
244
|
+
add("error", `${where}, but 'workflows/${target}/workflow.yaml' is missing or has no valid ` +
|
|
245
|
+
`machine spec.`, { field });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const targetMachine = WorkflowMachine.fromSpec(targetSpec);
|
|
249
|
+
// A caller enters where a host would: a machine with no `manual` entry
|
|
250
|
+
// has no state to start in. Worded to match the assembly-time diagnostic.
|
|
251
|
+
if (!targetMachine.startStateForTrigger(MANUAL_TRIGGER)) {
|
|
252
|
+
add("error", `${where}, but '${target}' declares no ` +
|
|
253
|
+
`'${MANUAL_TRIGGER}' trigger, so it has no state to start in. Add ` +
|
|
254
|
+
`"triggers: { ${MANUAL_TRIGGER}: }" to the state a run should start in.`, { field });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// A disabled target is a deliberate, reversible operational state, so a
|
|
258
|
+
// **warning** on the caller: the tool is still bound, only the dispatch
|
|
259
|
+
// refuses.
|
|
260
|
+
if (specDisabled(targetSpec)) {
|
|
261
|
+
add("warning", `${where}, but '${target}' is disabled ('disabled: true' in its workflow.yaml), so ` +
|
|
262
|
+
`that call will be refused at runtime. The tool is still bound and the rest of ` +
|
|
263
|
+
`this workflow still runs; re-enable '${target}' or stop allowing it.`, { field });
|
|
264
|
+
}
|
|
265
|
+
const nextChain = [...chain, target];
|
|
266
|
+
// Depth counts delegations, not workflows, so the root does not consume a level.
|
|
267
|
+
const depth = nextChain.length - 1;
|
|
268
|
+
if (depth > DEFAULT_SUB_WORKFLOW_DEPTH) {
|
|
269
|
+
add("error", `${where}, reaching ${depth} levels ` +
|
|
270
|
+
`(${nextChain.join(" → ")}), past the dispatcher's default depth bound of ${DEFAULT_SUB_WORKFLOW_DEPTH}.`, { field });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
await walk(target, nextChain, field);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await walk(workflowName, [workflowName], "states");
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The one wording for a grant of a tool no state may be given. `task` is the
|
|
281
|
+
* framework's dispatch for grading rubrics, which the runtime uses on its own
|
|
282
|
+
* authority: the entry reads as a grant but confers nothing, because disclosure
|
|
283
|
+
* withholds the tool and the kernel refuses the call.
|
|
284
|
+
*/
|
|
285
|
+
function ungrantableReason(where, tool) {
|
|
286
|
+
return (`${where} allows '${tool}', which is not grantable. It is the runtime's own dispatch for ` +
|
|
287
|
+
`grading rubrics — a rubric grades the agent rather than serving it — so the tool is ` +
|
|
288
|
+
`disclosed in no state and the kernel refuses the call. Remove the entry; to delegate work ` +
|
|
289
|
+
`to another process, allow that workflow's 'archmax_workflow_<slug>' tool instead.`);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Check what a hook *refers to*: a `script` file that exists in the workflow's
|
|
293
|
+
* `hooks/` directory and imports only from `@archmax-ai/harness/*`, or a custom kind
|
|
294
|
+
* declared under `extensions.hooks`. The hook's own shape is the schema's.
|
|
295
|
+
*
|
|
296
|
+
* A `{ rubric: … }` hook needs no check here: a rubric is declared in the same
|
|
297
|
+
* document, so the reference is resolved by the schema's own document-level
|
|
298
|
+
* refinement — one implementation for the loader and the validator, with no file
|
|
299
|
+
* read and no "exists but is unloadable" middle state to report.
|
|
300
|
+
*/
|
|
301
|
+
async function validateHookField(authoring, workflow, declared, slug, phase, field, add, expectedHookKinds) {
|
|
302
|
+
for (const hook of normalizeHooks(declared)) {
|
|
303
|
+
const kind = hookKind(hook);
|
|
304
|
+
// A malformed hook was reported by the schema; there is nothing to look up.
|
|
305
|
+
if (!kind)
|
|
306
|
+
continue;
|
|
307
|
+
if (kind === "script") {
|
|
308
|
+
const script = String(hook.script);
|
|
309
|
+
// Resolved with the same helper the runtime uses, so a misplaced hook is an
|
|
310
|
+
// offline diagnostic instead of a fail-closed veto mid-run.
|
|
311
|
+
const resolved = resolveHookScript(workflow, script);
|
|
312
|
+
if (!resolved.ok) {
|
|
313
|
+
add("error", `State '${slug}' '${phase}' ${resolved.reason}`, {
|
|
314
|
+
file: script,
|
|
315
|
+
field: `${field}.${phase}`,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const source = await authoring.readText(resolved.path);
|
|
320
|
+
if (source == null) {
|
|
321
|
+
add("error", `State '${slug}' '${phase}' hook script not found: ${resolved.path}`, {
|
|
322
|
+
file: resolved.path,
|
|
323
|
+
field: `${field}.${phase}`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
for (const specifier of findForeignImports(source)) {
|
|
328
|
+
add("error", `Hook script '${resolved.path}' imports from '${specifier}'; sandbox scripts may ` +
|
|
329
|
+
`import only from '@archmax-ai/harness/*' type-carrier entry points`, { file: resolved.path, field: `${field}.${phase}` });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else if (!BUILTIN_HOOK_KINDS.includes(kind) && !expectedHookKinds.has(kind)) {
|
|
334
|
+
// A custom kind is acceptable offline only when declared under
|
|
335
|
+
// `extensions.hooks`: the executor is registered at assembly, which the
|
|
336
|
+
// validator cannot see.
|
|
337
|
+
add("error", `State '${slug}' '${phase}' hook uses unknown kind '${kind}'; built-in kinds are ` +
|
|
338
|
+
`${BUILTIN_HOOK_KINDS.join(" and ")}. ` +
|
|
339
|
+
`Declare custom kinds under 'extensions.hooks' if an executor is registered at runtime.`, { field: `${field}.${phase}` });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Skill governance against the registry, resolved through the runtime's own
|
|
345
|
+
* discovery path. The runtime silently drops an unknown slug and intersects a
|
|
346
|
+
* state list with the root list; this is where an author is told about each.
|
|
347
|
+
*
|
|
348
|
+
* Two warnings carry the "enabled by nothing" case. A root slug no state enables
|
|
349
|
+
* is an editing leftover. A spec with **no root block at all**, in a workspace
|
|
350
|
+
* that serves bundles, is the shape that used to inherit them: it is told once,
|
|
351
|
+
* naming what it cannot reach. An explicit `allow: []` says the workflow uses no
|
|
352
|
+
* skill and is left alone — the registry is workspace-wide while enablement is
|
|
353
|
+
* per workflow, so a bundle another workflow owns is not this one's problem.
|
|
354
|
+
*/
|
|
355
|
+
async function validateSkills(workspace, machine, spec, specFile, add, sources) {
|
|
356
|
+
const { registry, issues } = await inspectSkillSources(workspace, sources);
|
|
357
|
+
for (const issue of issues) {
|
|
358
|
+
add(issue.severity, issue.message, { field: "skills" });
|
|
359
|
+
}
|
|
360
|
+
const known = [...registry.keys()];
|
|
361
|
+
const knownList = known.length > 0 ? known.join(", ") : "(the workspace provides none)";
|
|
362
|
+
/** The slugs one skills list names that the registry provides. */
|
|
363
|
+
const provided = (allow, field, where) => {
|
|
364
|
+
if (!Array.isArray(allow))
|
|
365
|
+
return undefined;
|
|
366
|
+
const slugs = [];
|
|
367
|
+
for (const slug of allow) {
|
|
368
|
+
if (typeof slug !== "string")
|
|
369
|
+
continue; // reported by the schema
|
|
370
|
+
if (!registry.has(slug)) {
|
|
371
|
+
add("error", `${where}'${field}' names skill '${slug}', which no source provides. ` +
|
|
372
|
+
`Known skills: ${knownList}.`, { file: specFile, field });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
slugs.push(slug);
|
|
376
|
+
}
|
|
377
|
+
return slugs;
|
|
378
|
+
};
|
|
379
|
+
const rootDeclared = spec.skills !== undefined;
|
|
380
|
+
// Called for their diagnostics: a slug no source provides is an error under
|
|
381
|
+
// every key, addressed at the key that named it.
|
|
382
|
+
provided(spec.skills?.allow_always, "skills.allow_always", "");
|
|
383
|
+
provided(spec.skills?.forbid_always, "skills.forbid_always", "");
|
|
384
|
+
for (const [slug, declared] of Object.entries(spec.states)) {
|
|
385
|
+
const state = (declared ?? {});
|
|
386
|
+
provided(state.skills?.allow, `states.${slug}.skills.allow`, `State '${slug}' `);
|
|
387
|
+
provided(state.skills?.forbid, `states.${slug}.skills.forbid`, `State '${slug}' `);
|
|
388
|
+
}
|
|
389
|
+
// The shape that used to inherit the whole registry. Said once, and only for an
|
|
390
|
+
// absent block: a declared list — `allow_always: []` included — is an author
|
|
391
|
+
// stating what this workflow grants workflow-wide.
|
|
392
|
+
if (!rootDeclared && known.length > 0) {
|
|
393
|
+
add("warning", `This workflow declares no root 'skills' block, so nothing here can read a bundle or run ` +
|
|
394
|
+
`its scripts. The workspace provides: ${knownList}. Add a root 'skills.allow_always' list ` +
|
|
395
|
+
`for the skills every state needs, name state-specific ones in a state's own ` +
|
|
396
|
+
`'skills.allow', or write 'skills: { allow_always: [] }' to say this workflow grants none ` +
|
|
397
|
+
`workflow-wide.`, { file: specFile, field: "skills" });
|
|
398
|
+
}
|
|
399
|
+
return registry;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Mount governance against the workspace's resolved table — the mounts twin of
|
|
403
|
+
* {@link validateSkills}, and the only place an author is told that a `mounts`
|
|
404
|
+
* entry reaches nothing.
|
|
405
|
+
*
|
|
406
|
+
* The table is the consumer's wiring, so a name it does not carry is an
|
|
407
|
+
* **error**: the runtime would drop it silently and the state would quietly
|
|
408
|
+
* reach less than the document says. Everything else is a warning about an inert
|
|
409
|
+
* entry, in the same family as the skills diagnostics: a grant on an ungoverned
|
|
410
|
+
* mount (visible everywhere already), a name at both levels, a state `allow: []`
|
|
411
|
+
* beside a non-empty always-on grant, a `forbid` governing nothing, and a
|
|
412
|
+
* `forbid_always` name some list also grants.
|
|
413
|
+
*/
|
|
414
|
+
function validateMounts(machine, spec, specFile, add, mounts) {
|
|
415
|
+
const known = [...mounts.dirs, ...mounts.files];
|
|
416
|
+
const knownList = known.length > 0 ? known.join(", ") : "(the workspace mounts none)";
|
|
417
|
+
const governedList = mounts.governed.length > 0
|
|
418
|
+
? mounts.governed.join(", ")
|
|
419
|
+
: "(the workspace governs none)";
|
|
420
|
+
/** Report every name one list carries that the table does not. */
|
|
421
|
+
const carried = (names, field, where) => {
|
|
422
|
+
if (!Array.isArray(names))
|
|
423
|
+
return [];
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const name of names) {
|
|
426
|
+
if (typeof name !== "string")
|
|
427
|
+
continue; // reported by the schema
|
|
428
|
+
if (!known.includes(name)) {
|
|
429
|
+
add("error", `${where}'${field}' names mount '${name}', which the workspace does not mount. ` +
|
|
430
|
+
`Mounts: ${knownList}.`, { file: specFile, field });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
out.push(name);
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* The grants one list carries that the table mounts, plus the diagnostic for
|
|
439
|
+
* an `access: read_write` the host's posture cannot honour — the write ceiling
|
|
440
|
+
* is the wiring's, so asking past it is inert rather than an error.
|
|
441
|
+
*/
|
|
442
|
+
const granted = (entries, field, where) => {
|
|
443
|
+
const grants = normalizeMountGrants(entries);
|
|
444
|
+
const names = carried(grants.map((grant) => grant.mount), field, where);
|
|
445
|
+
const kept = grants.filter((grant) => names.includes(grant.mount));
|
|
446
|
+
for (const grant of kept) {
|
|
447
|
+
if (grant.access !== "read_write" || mounts.writable.includes(grant.mount))
|
|
448
|
+
continue;
|
|
449
|
+
add("warning", `${where}'${field}' asks for 'access: read_write' on mount '${grant.mount}', which the ` +
|
|
450
|
+
`workspace serves read-only, so the entry opens nothing — the host's write posture is ` +
|
|
451
|
+
`the ceiling. Drop the access, or have the host mount it '{ readOnly: false }'.`, { file: specFile, field });
|
|
452
|
+
}
|
|
453
|
+
return kept;
|
|
454
|
+
};
|
|
455
|
+
const alwaysAllow = granted(spec.mounts?.allow_always, "mounts.allow_always", "").map((grant) => grant.mount);
|
|
456
|
+
// Called for its diagnostics: a name the table does not carry is an error
|
|
457
|
+
// under every key, addressed at the key that named it.
|
|
458
|
+
carried(spec.mounts?.forbid_always, "mounts.forbid_always", "");
|
|
459
|
+
/** A grant naming an ungoverned mount adds nothing: it is visible everywhere. */
|
|
460
|
+
const inertGrant = (names, field, where) => {
|
|
461
|
+
for (const name of names) {
|
|
462
|
+
if (mounts.governed.includes(name))
|
|
463
|
+
continue;
|
|
464
|
+
add("warning", `${where}'${field}' names mount '${name}', which the workspace does not declare ` +
|
|
465
|
+
`governed, so the entry grants nothing — an ungoverned mount is visible in every ` +
|
|
466
|
+
`state already. Governed mounts: ${governedList}. Use 'mounts.forbid' to take one ` +
|
|
467
|
+
`away from a state.`, { file: specFile, field });
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
inertGrant(alwaysAllow, "mounts.allow_always", "");
|
|
471
|
+
for (const [slug, declared] of Object.entries(spec.states)) {
|
|
472
|
+
const state = (declared ?? {});
|
|
473
|
+
const at = `states.${slug}.mounts`;
|
|
474
|
+
const where = `State '${slug}' `;
|
|
475
|
+
const allow = granted(state.mounts?.allow, `${at}.allow`, where).map((grant) => grant.mount);
|
|
476
|
+
const forbid = carried(state.mounts?.forbid, `${at}.forbid`, where);
|
|
477
|
+
inertGrant(allow, `${at}.allow`, where);
|
|
478
|
+
// A `forbid` governs nothing when nothing would have reached the mount here:
|
|
479
|
+
// an ungoverned mount is always reachable, so only a governed one can be inert.
|
|
480
|
+
for (const name of forbid) {
|
|
481
|
+
if (!mounts.governed.includes(name))
|
|
482
|
+
continue;
|
|
483
|
+
const wouldReach = alwaysAllow.includes(name) || allow.includes(name);
|
|
484
|
+
if (wouldReach)
|
|
485
|
+
continue;
|
|
486
|
+
add("warning", `${where}forbids mount '${name}', which nothing enables here — neither ` +
|
|
487
|
+
`'mounts.allow_always' nor this state's own 'mounts.allow' names it — so the entry ` +
|
|
488
|
+
`governs nothing.`, { file: specFile, field: `${at}.forbid` });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// The shape that reaches none of the workspace's governed content. Said once,
|
|
492
|
+
// and only for an absent block: a declared list — `allow_always: []`
|
|
493
|
+
// included — is an author stating what this workflow reaches.
|
|
494
|
+
if (spec.mounts === undefined && mounts.governed.length > 0) {
|
|
495
|
+
add("warning", `This workflow declares no root 'mounts' block, so it can reach none of the workspace's ` +
|
|
496
|
+
`governed mounts: ${governedList}. Add a root 'mounts.allow_always' list for the ones ` +
|
|
497
|
+
`every state needs, name state-specific ones in a state's own 'mounts.allow', or write ` +
|
|
498
|
+
`'mounts: { allow_always: [] }' to say this workflow reaches none workflow-wide.`, { file: specFile, field: "mounts" });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Kernel probes for one state's `tools.allow`: entries the spec grants that the
|
|
503
|
+
* runtime would nonetheless refuse (a runtime-owned or read-only zone, a
|
|
504
|
+
* denied path or tool, an `archmax_run` path outside every skill bundle,
|
|
505
|
+
* a path inside a bundle the state does not enable, the authoring plane) and
|
|
506
|
+
* `archmax_advance` targets with no matching edge. Decided through the same
|
|
507
|
+
* kernel the runtime uses, so the diagnostic and the enforcement cannot diverge.
|
|
508
|
+
*/
|
|
509
|
+
function validateStateGovernance(machine, states, slug, state, field, add, mountPrefixes = NO_MOUNTS, skillRegistry = new Map()) {
|
|
510
|
+
const entries = (state.tools?.allow ?? []).map(normalizeAllowEntry);
|
|
511
|
+
for (const { tool } of entries) {
|
|
512
|
+
if (tool && UNGRANTABLE_TOOLS.has(tool)) {
|
|
513
|
+
add("error", ungrantableReason(`State '${slug}'`, tool), { field: `${field}.tools.allow` });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const { tool, argMatchers } of entries) {
|
|
517
|
+
if (tool !== ADVANCE_TOOL || !argMatchers)
|
|
518
|
+
continue;
|
|
519
|
+
for (const target of argMatchers.to ?? []) {
|
|
520
|
+
if (target.includes("*"))
|
|
521
|
+
continue;
|
|
522
|
+
if (!(target in states)) {
|
|
523
|
+
add("warning", `State '${slug}' allows ${ADVANCE_TOOL} to undefined state '${target}'`, {
|
|
524
|
+
field: `${field}.tools.allow`,
|
|
525
|
+
});
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// An allowed advance target with no declared edge passes tool governance
|
|
529
|
+
// but the kernel blocks the transition.
|
|
530
|
+
const edge = decide(machine, { kind: "transition", from: slug, to: target, hookFacts: [] });
|
|
531
|
+
if (edge.decision === "block") {
|
|
532
|
+
add("warning", `State '${slug}' allows ${ADVANCE_TOOL} to '${target}' but declares no transition edge to it; the runtime will block the transition`, { field: `${field}.transitions` });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// `archmax_run` is confined to skill bundles by a non-overridable safety rule,
|
|
537
|
+
// so an entry naming paths outside one is inert — and reads as permissive.
|
|
538
|
+
const bundlePrefixes = skillPrefixes(skillRegistry);
|
|
539
|
+
/** Whether a pattern can match anything inside a skill bundle (or an ancestor of one). */
|
|
540
|
+
const reachesABundle = (pattern) => {
|
|
541
|
+
if (skillOfPattern(pattern, bundlePrefixes) != null)
|
|
542
|
+
return true;
|
|
543
|
+
const literal = normalizeRelPath(pattern).split(/[*?[{]/, 1)[0] ?? "";
|
|
544
|
+
const upTo = literal.slice(0, literal.lastIndexOf("/") + 1);
|
|
545
|
+
return upTo !== "" && bundlePrefixes.some(({ prefix }) => `${prefix}/`.startsWith(upTo));
|
|
546
|
+
};
|
|
547
|
+
for (const { tool, argMatchers } of entries) {
|
|
548
|
+
if (tool !== RUN_TOOL || !argMatchers)
|
|
549
|
+
continue;
|
|
550
|
+
const inert = (argMatchers.file_path ?? []).filter((pattern) => !reachesABundle(pattern));
|
|
551
|
+
if (inert.length === 0)
|
|
552
|
+
continue;
|
|
553
|
+
add("error", `State '${slug}' allows '${RUN_TOOL}' on ${inert.map((p) => `'${p}'`).join(", ")}, which is ` +
|
|
554
|
+
`outside every skill bundle. '${RUN_TOOL}' may only execute scripts in a skill bundle ` +
|
|
555
|
+
`(rule 'script.skill-only'), so this entry grants nothing — scope it to a capability's ` +
|
|
556
|
+
`scripts (e.g. 'skills/<name>/scripts/**'). Lifecycle hook scripts are not run this way: ` +
|
|
557
|
+
`they live in 'workflows/<slug>/hooks/' and are executed by the harness.`, { field: `${field}.tools.allow` });
|
|
558
|
+
}
|
|
559
|
+
// An entry naming the authoring plane: no plane prefix is routed into the
|
|
560
|
+
// agent's workspace, so such a path silently resolves inside the run's own zone.
|
|
561
|
+
for (const { tool, argMatchers } of entries) {
|
|
562
|
+
if (!argMatchers)
|
|
563
|
+
continue;
|
|
564
|
+
for (const filePath of argMatchers.file_path ?? []) {
|
|
565
|
+
const { path: rel } = canonicalizeRelPath(filePath);
|
|
566
|
+
const prefix = authoringPlanePrefix(rel);
|
|
567
|
+
if (!prefix)
|
|
568
|
+
continue;
|
|
569
|
+
add("error", `State '${slug}' allows '${tool}' on '${filePath}', which names the authoring ` +
|
|
570
|
+
`plane ('${prefix}/'). That prefix holds ${describeAuthoringPrefix(prefix)}; ` +
|
|
571
|
+
`it is served to the harness by the authoring backend and is not routed into the ` +
|
|
572
|
+
`agent's workspace, so this path resolves inside the run's own zone instead and the entry ` +
|
|
573
|
+
`does not mean what it reads as.`, { field: `${field}.tools.allow` });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Writes the kernel will refuse even though the state's list permits them.
|
|
577
|
+
for (const { tool, argMatchers } of entries) {
|
|
578
|
+
if ((tool !== "write_file" && tool !== "edit_file") || !argMatchers)
|
|
579
|
+
continue;
|
|
580
|
+
for (const filePath of argMatchers.file_path ?? []) {
|
|
581
|
+
// The zone checks apply to globs too (`checkpoints/**` shares the prefix),
|
|
582
|
+
// so they run before the concrete-path kernel probe below.
|
|
583
|
+
const zone = classifyWorkspacePath(filePath);
|
|
584
|
+
if (zone === "run-internal" || zone === "run-offload") {
|
|
585
|
+
add("error", `State '${slug}' allows '${tool}' on '${filePath}', which targets a runtime-owned run area; the runtime will block it. Write under '${SESSION_OPEN_DIR}/' instead.`, { field: `${field}.tools.allow` });
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (filePath.includes("*"))
|
|
589
|
+
continue;
|
|
590
|
+
const verdict = decide(machine, { kind: "tool-call", state: slug, tool, args: { file_path: filePath } }, [], mountPrefixes);
|
|
591
|
+
if (verdict.ruleId === "zone.read-only") {
|
|
592
|
+
add("error", `State '${slug}' allows '${tool}' on '${filePath}', which is in the read-only authored zone; the runtime will block it. Write under '${SESSION_OPEN_DIR}/' instead.`, { field: `${field}.tools.allow` });
|
|
593
|
+
}
|
|
594
|
+
else if (verdict.ruleId === "tool.forbidden" || verdict.ruleId === "tool.forbidden-here") {
|
|
595
|
+
const where = verdict.ruleId === "tool.forbidden"
|
|
596
|
+
? "the workflow denies in every state ('tools.forbid_always')"
|
|
597
|
+
: `this state denies ('states.${slug}.tools.forbid')`;
|
|
598
|
+
add("error", `State '${slug}' allows '${tool}' on '${filePath}', which ${where}; a denial beats every grant, so the runtime will block it.`, { field: `${field}.tools.allow` });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// A path entry that can only ever match inside a skill bundle this state does
|
|
603
|
+
// not enable: the runtime refuses every call it would permit.
|
|
604
|
+
if (skillRegistry.size > 0) {
|
|
605
|
+
const table = skillPrefixes(skillRegistry);
|
|
606
|
+
const enabled = new Set(machine.enabledSkills(slug, [...skillRegistry.keys()]));
|
|
607
|
+
for (const { tool, argMatchers } of entries) {
|
|
608
|
+
if (!tool || !argMatchers)
|
|
609
|
+
continue;
|
|
610
|
+
for (const pattern of argMatchers.file_path ?? []) {
|
|
611
|
+
const owner = skillOfPattern(pattern, table);
|
|
612
|
+
if (owner == null || enabled.has(owner))
|
|
613
|
+
continue;
|
|
614
|
+
add("error", `State '${slug}' allows '${tool}' on '${pattern}', which can only match inside skill ` +
|
|
615
|
+
`'${owner}' — a skill this state does not enable, so the runtime blocks every such ` +
|
|
616
|
+
`call. Add '${owner}' to this state's 'skills.allow' (or the workflow's ` +
|
|
617
|
+
`'skills.allow_always'), check it is not denied by a 'forbid' list, or drop the entry.`, { field: `${field}.tools.allow` });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// A path entry that can only ever match inside a mount this state cannot
|
|
622
|
+
// reach: the runtime refuses every call it would permit. A warning rather than
|
|
623
|
+
// an error, because the mount table is the host's wiring and a workflow may
|
|
624
|
+
// legitimately be authored against one workspace and validated in another.
|
|
625
|
+
if (mountPrefixes.governed.length > 0) {
|
|
626
|
+
const enabled = new Set(machine.enabledMounts(slug, mountPrefixes.governed));
|
|
627
|
+
for (const { tool, argMatchers } of entries) {
|
|
628
|
+
if (!tool || !argMatchers)
|
|
629
|
+
continue;
|
|
630
|
+
for (const pattern of [...(argMatchers.file_path ?? []), ...(argMatchers.path ?? [])]) {
|
|
631
|
+
const owner = mountNameOfPattern(pattern, mountPrefixes);
|
|
632
|
+
if (owner == null || !mountPrefixes.governed.includes(owner) || enabled.has(owner))
|
|
633
|
+
continue;
|
|
634
|
+
add("warning", `State '${slug}' allows '${tool}' on '${pattern}', which can only match inside mount ` +
|
|
635
|
+
`'${owner}' — a mount this state does not enable, so the runtime blocks every such ` +
|
|
636
|
+
`call and the grant reaches nothing. Add '${owner}' to this state's 'mounts.allow' ` +
|
|
637
|
+
`(or the workflow's 'mounts.allow_always'), check no 'forbid' list denies it, or ` +
|
|
638
|
+
`drop the entry.`, { field: `${field}.tools.allow` });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// A tool a denial covers outright: blocked wherever the denial reaches, so the
|
|
643
|
+
// grant beside it governs nothing.
|
|
644
|
+
for (const { tool } of entries) {
|
|
645
|
+
if (!tool)
|
|
646
|
+
continue;
|
|
647
|
+
const verdict = decide(machine, { kind: "tool-call", state: slug, tool, args: {} }, [], mountPrefixes);
|
|
648
|
+
if (verdict.ruleId === "tool.forbidden") {
|
|
649
|
+
add("error", `State '${slug}' allows '${tool}', which 'tools.forbid_always' denies in every state; a denial beats every grant, so the runtime will block it everywhere.`, { field: `${field}.tools.allow` });
|
|
650
|
+
}
|
|
651
|
+
else if (verdict.ruleId === "tool.forbidden-here") {
|
|
652
|
+
add("error", `State '${slug}' allows '${tool}' and forbids it in the same state ('states.${slug}.tools.forbid'); the denial wins, so the runtime will block every such call.`, { field: `${field}.tools.allow` });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Check a state's slug: present, and shaped like a slug. Static only — the
|
|
658
|
+
* runtime treats a slug as opaque, so a nonconforming slug still runs.
|
|
659
|
+
*/
|
|
660
|
+
function validateStateSlug(slug, specFile, add) {
|
|
661
|
+
if (slug.trim() === "") {
|
|
662
|
+
add("error", `A state is declared with an empty slug; a state's key IS its slug — the identity every transition target, trigger id, and test assertion references — so give it a kebab-case slug (e.g. 'identify-case') and put any human-readable label in 'title'`, { file: specFile, field: "states" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (SLUG_PATTERN.test(slug))
|
|
666
|
+
return;
|
|
667
|
+
add("error", `State slug '${slug}' is not a valid slug; use hyphen-separated kebab-case — lowercase letters and digits in segments joined by single hyphens (e.g. 'identify-case', 'refund-review'), with no underscores, capitals, or leading/trailing hyphens. The slug is the state's identity — every transition target, trigger id, and test assertion references it, and the agent passes it to archmax_advance — so give the state a 'title' for its human-readable label instead`, { file: specFile, field: `states.${slug}` });
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=validate.js.map
|