mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-05 20:31:54 +00:00
Checkpoint portable TORCH fleet, provider adapters and owner dashboard
This commit is contained in:
1 parent
d31437a4fb
commit
03fcbdd23f
140 files changed
+26152
-757
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { trackedFiles } from './git.mjs';
|
||||
import { workingTreeFiles, workingTreeFingerprint } from './git.mjs';
|
||||
import { buildArchitectureGraph } from './domains.mjs';
|
||||
|
||||
const LANGUAGE_BY_EXTENSION = new Map([
|
||||
@@ -20,7 +20,8 @@ const MANIFESTS = [
|
||||
];
|
||||
|
||||
export function analyzeRepository(repository, { specifications = [] } = {}) {
|
||||
const files = trackedFiles(repository.root);
|
||||
const workingTree = workingTreeFiles(repository.root);
|
||||
const files = workingTree.files;
|
||||
const languageCounts = new Map();
|
||||
const topLevel = new Set();
|
||||
|
||||
@@ -70,9 +71,12 @@ export function analyzeRepository(repository, { specifications = [] } = {}) {
|
||||
branch: repository.branch,
|
||||
head: repository.head,
|
||||
hasRemote: repository.remotes.length > 0,
|
||||
workingTreeFingerprint: workingTreeFingerprint(repository.root, files),
|
||||
},
|
||||
inventory: {
|
||||
trackedFileCount: files.length,
|
||||
trackedFileCount: workingTree.trackedFileCount,
|
||||
untrackedFileCount: workingTree.untrackedFileCount,
|
||||
analyzedFileCount: files.length,
|
||||
languages,
|
||||
manifests,
|
||||
topLevelDirectories: [...topLevel].sort(),
|
||||
|
||||
+195
-3
@@ -2,12 +2,14 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { TorchError } from './errors.mjs';
|
||||
import { organizationGraphFromConfig, organizationGraphSchema } from './organization.mjs';
|
||||
|
||||
export const PROJECT_CONFIG_SCHEMA = 'torch.dev/v1alpha1';
|
||||
|
||||
const text = z.string().trim().min(1);
|
||||
const id = text.regex(/^[a-z0-9][a-z0-9-]*$/);
|
||||
const textList = z.array(text);
|
||||
const launchPolicy = z.record(z.string().regex(/^[a-z][A-Za-z0-9]*$/), text).optional();
|
||||
|
||||
const domain = z.object({
|
||||
id,
|
||||
@@ -22,6 +24,8 @@ const domain = z.object({
|
||||
resources: textList,
|
||||
runtime: text,
|
||||
model: text.nullable().optional(),
|
||||
reasoning: text.nullable().optional(),
|
||||
launchPolicy,
|
||||
branch: text.nullable().optional(),
|
||||
worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(),
|
||||
}).strict();
|
||||
@@ -33,6 +37,21 @@ const check = z.object({
|
||||
args: textList.optional(),
|
||||
source: z.record(z.string(), z.unknown()).optional(),
|
||||
resources: textList.optional(),
|
||||
snapshot: z.object({
|
||||
paths: textList.min(1),
|
||||
source_commit_file: text,
|
||||
}).strict().optional(),
|
||||
conditions: z.object({
|
||||
command: text,
|
||||
args: textList.optional(),
|
||||
timeout_seconds: z.number().int().positive().max(300).optional(),
|
||||
required: z.array(z.object({
|
||||
id,
|
||||
equals: z.union([z.string(), z.boolean(), z.number().finite(), z.null()]),
|
||||
}).strict()).min(1).refine((requirements) =>
|
||||
new Set(requirements.map((requirement) => requirement.id)).size === requirements.length,
|
||||
'Condition IDs must be unique'),
|
||||
}).strict().optional(),
|
||||
}).strict();
|
||||
|
||||
const resource = z.object({
|
||||
@@ -53,8 +72,28 @@ const schedule = z.object({
|
||||
z.object({ type: z.literal('interval'), seconds: z.number().int().positive() }).strict(),
|
||||
z.object({ type: z.literal('cron'), expression: text }).strict(),
|
||||
]),
|
||||
behavior: z.enum(['read-only', 'mutating']),
|
||||
action: z.object({ type: z.literal('command'), command: text, args: textList }).strict(),
|
||||
behavior: z.enum(['read-only', 'coordination', 'mutating']),
|
||||
action: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('command'), command: text, args: textList }).strict(),
|
||||
z.object({
|
||||
type: z.literal('manager-check-in'), manager_id: id,
|
||||
stale_work: z.object({
|
||||
stale_days: z.number().int().min(1).max(365).optional(),
|
||||
max_commits: z.number().int().min(1).max(10_000).optional(),
|
||||
max_items: z.number().int().min(1).max(100).optional(),
|
||||
}).strict().optional(),
|
||||
wake: z.object({
|
||||
enabled: z.boolean(),
|
||||
budget_mode: z.enum(['invocation-count', 'usd-hard-cap']).optional(),
|
||||
max_usd_per_invocation: z.number().finite().positive().optional(),
|
||||
}).strict().optional(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('integration-drain'), landing_authority_id: id,
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}).strict(),
|
||||
z.object({ type: z.literal('owner-digest') }).strict(),
|
||||
]),
|
||||
required_authority: textList.min(1),
|
||||
retry: z.object({ max_attempts: z.number().int().min(1).max(10) }).strict().optional(),
|
||||
failure_recipient: text.optional(),
|
||||
@@ -89,8 +128,19 @@ export const projectConfigSchema = z.object({
|
||||
message: 'runtimes.default must name a configured runtime',
|
||||
}),
|
||||
checks: z.array(check),
|
||||
backlog: z.object({
|
||||
self_claim: z.object({ enabled: z.boolean(), areas: textList }).strict().optional(),
|
||||
activity: z.object({
|
||||
stale_days: z.number().int().min(1).max(365).optional(),
|
||||
max_commits: z.number().int().min(1).max(10_000).optional(),
|
||||
auto_close_after_landing: z.boolean().optional(),
|
||||
}).strict().optional(),
|
||||
}).strict().optional(),
|
||||
resources: z.array(resource),
|
||||
schedules: z.array(schedule),
|
||||
runtime_wake_budget: z.object({
|
||||
max_invocations_per_day: z.number().int().min(1).max(24),
|
||||
}).strict().optional(),
|
||||
integration: z.object({
|
||||
provider: text,
|
||||
target: text,
|
||||
@@ -99,6 +149,7 @@ export const projectConfigSchema = z.object({
|
||||
landing_authority: textList.min(1),
|
||||
}).strict(),
|
||||
delivery: z.object({
|
||||
retry: z.object({ max_attempts: z.number().int().min(1).max(5) }).strict().optional(),
|
||||
authority: z.object({
|
||||
implemented: textList.min(1),
|
||||
verified: textList.min(1),
|
||||
@@ -113,14 +164,26 @@ export const projectConfigSchema = z.object({
|
||||
deployment: z.object({ provider: text }).passthrough(),
|
||||
}).strict(),
|
||||
}).strict(),
|
||||
owner_digest: z.object({ enabled: z.boolean(), window_hours: z.number().int().min(1).max(168).optional(),
|
||||
max_items: z.number().int().min(1).max(100).optional() }).strict().optional(),
|
||||
session_manager: z.object({
|
||||
id: z.literal('session-manager'),
|
||||
runtime: text,
|
||||
start_last: z.literal(true),
|
||||
model: text.nullable().optional(),
|
||||
reasoning: text.nullable().optional(),
|
||||
launchPolicy,
|
||||
// Older installations carried this launch-order hint. Current lifecycle
|
||||
// ordering is derived from the organization graph; retain only for reads.
|
||||
start_last: z.literal(true).optional(),
|
||||
branch: text.nullable().optional(),
|
||||
worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(),
|
||||
}).strict(),
|
||||
domains: z.array(domain).min(1),
|
||||
organization: organizationGraphSchema.optional(),
|
||||
organization_assessment: z.object({
|
||||
observation_window_days: z.number().int().min(7).max(365),
|
||||
minimum_recurrences: z.number().int().min(3).max(10),
|
||||
}).strict().optional(),
|
||||
retired_domains: z.array(z.object({
|
||||
id,
|
||||
title: text,
|
||||
@@ -130,6 +193,124 @@ export const projectConfigSchema = z.object({
|
||||
}).strict()).optional(),
|
||||
}).strict().superRefine((config, context) => {
|
||||
const runtimeNames = new Set(Object.keys(config.runtimes).filter((name) => name !== 'default'));
|
||||
const scheduleIds = new Set();
|
||||
const identityIds = new Set(['session-manager', ...config.domains.map((entry) => entry.id)]);
|
||||
for (const [index, area] of (config.backlog?.self_claim?.areas ?? []).entries()) {
|
||||
if (area === 'session-manager' || !identityIds.has(area)) context.addIssue({
|
||||
code: 'custom', path: ['backlog', 'self_claim', 'areas', index],
|
||||
message: 'self-claim allowlist must name a defined specialist identity',
|
||||
});
|
||||
}
|
||||
for (const [index, entry] of config.schedules.entries()) {
|
||||
if (scheduleIds.has(entry.id)) {
|
||||
context.addIssue({ code: 'custom', path: ['schedules', index, 'id'], message: `duplicate schedule id: ${entry.id}` });
|
||||
}
|
||||
scheduleIds.add(entry.id);
|
||||
if (entry.action.type === 'manager-check-in') {
|
||||
if (entry.behavior !== 'coordination' || entry.lifetime !== 'system'
|
||||
|| !entry.required_authority.includes('owner')) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index],
|
||||
message: 'manager check-in must be an owner-authorized system coordination schedule',
|
||||
});
|
||||
}
|
||||
if (entry.trigger.type === 'interval' && entry.trigger.seconds < 60) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'trigger', 'seconds'],
|
||||
message: 'manager check-in intervals must be at least 60 seconds',
|
||||
});
|
||||
}
|
||||
if (!identityIds.has(entry.action.manager_id)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'manager_id'],
|
||||
message: `manager check-in references unknown identity: ${entry.action.manager_id}`,
|
||||
});
|
||||
}
|
||||
const wake = entry.action.wake;
|
||||
const mode = wake?.budget_mode ?? (wake?.max_usd_per_invocation !== undefined ? 'usd-hard-cap' : null);
|
||||
if (wake?.enabled === true && mode === null) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'wake', 'budget_mode'],
|
||||
message: 'enabled manager runtime wakes require an explicit invocation-count policy or a positive per-invocation USD ceiling',
|
||||
});
|
||||
}
|
||||
if (mode === 'usd-hard-cap' && !(Number.isFinite(wake.max_usd_per_invocation)
|
||||
&& wake.max_usd_per_invocation > 0)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'wake', 'max_usd_per_invocation'],
|
||||
message: 'enabled manager runtime wakes require a positive per-invocation USD ceiling',
|
||||
});
|
||||
}
|
||||
if (mode === 'invocation-count' && wake?.max_usd_per_invocation !== undefined) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'wake', 'max_usd_per_invocation'],
|
||||
message: 'invocation-count mode cannot promise a USD ceiling; select usd-hard-cap instead',
|
||||
});
|
||||
}
|
||||
} else if (entry.action.type === 'integration-drain') {
|
||||
if (entry.behavior !== 'mutating' || entry.lifetime !== 'system'
|
||||
|| !['interval', 'cron'].includes(entry.trigger.type)
|
||||
|| !entry.required_authority.includes('owner')) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index],
|
||||
message: 'integration drain must be an owner-authorized periodic system mutation schedule',
|
||||
});
|
||||
}
|
||||
if (!identityIds.has(entry.action.landing_authority_id)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'landing_authority_id'],
|
||||
message: `integration drain references unknown identity: ${entry.action.landing_authority_id}`,
|
||||
});
|
||||
}
|
||||
if (!config.integration.landing_authority.includes(entry.action.landing_authority_id)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'action', 'landing_authority_id'],
|
||||
message: `integration drain identity lacks configured landing authority: ${entry.action.landing_authority_id}`,
|
||||
});
|
||||
}
|
||||
if (entry.trigger.type === 'interval' && entry.trigger.seconds < 60) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'trigger', 'seconds'],
|
||||
message: 'integration-drain intervals must be at least 60 seconds',
|
||||
});
|
||||
}
|
||||
} else if (entry.action.type === 'owner-digest') {
|
||||
if (entry.behavior !== 'coordination' || entry.lifetime !== 'system'
|
||||
|| !entry.required_authority.includes('owner') || (entry.retry?.max_attempts ?? 1) !== 1) {
|
||||
context.addIssue({ code: 'custom', path: ['schedules', index],
|
||||
message: 'owner digest must be an owner-authorized system coordination schedule without automatic retry' });
|
||||
}
|
||||
} else if (entry.behavior === 'coordination') {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules', index, 'behavior'],
|
||||
message: 'coordination behavior is reserved for the typed manager-check-in action',
|
||||
});
|
||||
}
|
||||
}
|
||||
const managerRoleIds = new Set(config.organization
|
||||
? config.organization.roles.flatMap((role) => role.reports_to)
|
||||
: []);
|
||||
const scheduledManagers = new Set(config.schedules
|
||||
.filter((entry) => entry.action.type === 'manager-check-in')
|
||||
.map((entry) => entry.action.manager_id));
|
||||
for (const managerIdentity of new Set(organizationGraphFromConfig(config).roles
|
||||
.filter((role) => managerRoleIds.has(role.id) && role.kind !== 'owner')
|
||||
.map((role) => role.identity_id))) {
|
||||
if (!scheduledManagers.has(managerIdentity)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['schedules'],
|
||||
message: `manager identity ${managerIdentity} has direct reports but no manager-check-in schedule`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const wakeEnabled = config.schedules.some((entry) =>
|
||||
entry.action.type === 'manager-check-in' && entry.action.wake?.enabled === true);
|
||||
if (wakeEnabled && !config.runtime_wake_budget) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['runtime_wake_budget'],
|
||||
message: 'enabled manager runtime wakes require a project-wide daily invocation budget',
|
||||
});
|
||||
}
|
||||
if (!runtimeNames.has(config.runtimes.default)) {
|
||||
context.addIssue({ code: 'custom', path: ['runtimes', 'default'], message: 'default runtime is not configured' });
|
||||
}
|
||||
@@ -138,6 +319,17 @@ export const projectConfigSchema = z.object({
|
||||
context.addIssue({ code: 'custom', path: ['domains', index, 'runtime'], message: 'domain runtime is not configured' });
|
||||
}
|
||||
}
|
||||
for (const [runtime, profile] of Object.entries(config.runtimes)) {
|
||||
if (runtime === 'default' || !profile || typeof profile !== 'object' || Array.isArray(profile)) continue;
|
||||
if (profile.launchPolicy !== undefined) {
|
||||
const result = launchPolicy.safeParse(profile.launchPolicy);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) context.addIssue({
|
||||
...issue, path: ['runtimes', runtime, 'launchPolicy', ...issue.path],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function formatIssues(issues) {
|
||||
|
||||
+65
-7
@@ -7,15 +7,19 @@ import { inspectManagedWorktree } from './worktree-state.mjs';
|
||||
import { validateProjectConfig } from './config.mjs';
|
||||
import { forgeStatus } from '../forge/service.mjs';
|
||||
import { classifyRecoverability } from '../canonical/local.mjs';
|
||||
import { createClaudeAdapter } from '../adapters/claude.mjs';
|
||||
import { createCodexAdapter } from '../adapters/codex.mjs';
|
||||
import { createRuntimeAdapterRegistry } from '../adapters/registry.mjs';
|
||||
import { profileCapabilityIssues, resolveLaunchPolicy } from '../adapters/runtime.mjs';
|
||||
import { assessBacklogHealth, backlogObservedCommitDistance } from '../backlog/service.mjs';
|
||||
import { organizationGraphFromConfig } from './organization.mjs';
|
||||
|
||||
function parseJsonYaml(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
export function diagnoseProject({ repository, env = process.env, now = () => new Date() }) {
|
||||
export function diagnoseProject({
|
||||
repository, env = process.env, now = () => new Date(),
|
||||
runtimeRegistry = createRuntimeAdapterRegistry({ env }),
|
||||
}) {
|
||||
const trackedRoot = join(repository.root, '.torch');
|
||||
const configPath = join(trackedRoot, 'torch.yaml');
|
||||
const manifestPath = join(trackedRoot, 'install-manifest.json');
|
||||
@@ -39,6 +43,26 @@ export function diagnoseProject({ repository, env = process.env, now = () => new
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
if (config) {
|
||||
try {
|
||||
const graph = organizationGraphFromConfig(config);
|
||||
const activeIdentities = new Set([
|
||||
'owner', 'session-manager', ...(config.domains ?? []).map((domain) => domain.id),
|
||||
]);
|
||||
const missing = [...new Set(graph.roles
|
||||
.map((role) => role.identity_id)
|
||||
.filter((identity) => !activeIdentities.has(identity)))];
|
||||
if (missing.length) findings.push({
|
||||
severity: 'error', code: 'ORGANIZATION_IDENTITY_MISSING', identities: missing,
|
||||
recommendation: 'Review the approved organization graph against the active Fleet roster; role references do not create identities.',
|
||||
});
|
||||
} catch (error) {
|
||||
findings.push({
|
||||
severity: 'error', code: 'ORGANIZATION_GRAPH_INVALID',
|
||||
message: error.message, details: error.details,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const record of manifest.created ?? []) {
|
||||
const path = join(repository.root, record.path);
|
||||
if (!existsSync(path)) findings.push({ severity: 'error', code: 'OWNED_FILE_MISSING', path: record.path });
|
||||
@@ -220,13 +244,24 @@ export function diagnoseProject({ repository, env = process.env, now = () => new
|
||||
findings.push({ severity: 'error', code: 'RECOVERABILITY_INVALID', message: error.message });
|
||||
}
|
||||
if (config?.runtimes) {
|
||||
const adapters = new Map([
|
||||
['claude', createClaudeAdapter({ env })],
|
||||
['codex', createCodexAdapter({ env })],
|
||||
]);
|
||||
const adapters = runtimeRegistry;
|
||||
for (const pluginError of adapters.pluginErrors ?? []) findings.push({
|
||||
severity: 'warning', code: pluginError.code, runtime: pluginError.name,
|
||||
message: pluginError.message, details: pluginError.details,
|
||||
recommendation: 'Review the user-local runtime plugin trust record and the exact adapter module before retrying.',
|
||||
});
|
||||
for (const runtime of Object.keys(config.runtimes).filter((name) => name !== 'default')) {
|
||||
const adapter = adapters.get(runtime);
|
||||
if (!adapter) {
|
||||
const trustedPlugin = adapters.trustedPlugins?.find((plugin) => plugin.name === runtime);
|
||||
if (trustedPlugin?.status === 'trusted') {
|
||||
findings.push({
|
||||
severity: 'info', code: 'RUNTIME_ADAPTER_TRUSTED_NOT_LOADED', runtime,
|
||||
modulePath: trustedPlugin.modulePath, sha256: trustedPlugin.sha256,
|
||||
recommendation: 'The user-local entrypoint hash is trusted. Doctor did not execute it; validate the adapter only in an explicit runtime operation.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
severity: 'warning', code: 'RUNTIME_ADAPTER_UNKNOWN', runtime,
|
||||
recommendation: 'Install or configure a TORCH runtime adapter before starting this identity.',
|
||||
@@ -242,6 +277,29 @@ export function diagnoseProject({ repository, env = process.env, now = () => new
|
||||
: `Install ${runtime} or change the approved runtime assignment before startup.`,
|
||||
});
|
||||
}
|
||||
const assigned = [
|
||||
{ id: 'session-manager', ...config.session_manager },
|
||||
...config.domains,
|
||||
];
|
||||
for (const area of assigned) {
|
||||
const runtime = area.runtime ?? config.runtimes.default;
|
||||
const adapter = adapters.get(runtime);
|
||||
if (!adapter) continue;
|
||||
const profile = {
|
||||
model: area.model ?? config.runtimes[runtime]?.model ?? adapter.configuration?.model ?? null,
|
||||
reasoning: area.reasoning ?? config.runtimes[runtime]?.reasoning ?? adapter.configuration?.reasoning ?? null,
|
||||
launchPolicy: resolveLaunchPolicy({
|
||||
runtime, adapterDefaults: adapter.configuration,
|
||||
runtimeConfig: config.runtimes[runtime], identity: area,
|
||||
}),
|
||||
};
|
||||
const unsupported = profileCapabilityIssues(adapter, profile);
|
||||
if (unsupported.length) findings.push({
|
||||
severity: 'error', code: 'RUNTIME_PROFILE_UNSUPPORTED', areaId: area.id,
|
||||
runtime, adapter: adapter.name, profile, capabilities: unsupported,
|
||||
recommendation: 'Assign a model/reasoning profile supported by this identity runtime adapter.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (config?.forge?.provider && config.forge.provider !== 'none') {
|
||||
try {
|
||||
|
||||
+98
-22
@@ -1,6 +1,8 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { posix } from 'node:path';
|
||||
import { join, posix } from 'node:path';
|
||||
import { TorchError } from './errors.mjs';
|
||||
import { organizationGraphFromConfig, planManagerCheckInSchedules } from './organization.mjs';
|
||||
import { workingTreeFingerprint } from './git.mjs';
|
||||
import { validateSpecificationEvidence } from './specifications.mjs';
|
||||
|
||||
const SOURCE_ROOTS = new Set(['src', 'app', 'apps', 'server', 'client', 'lib', 'packages', 'services']);
|
||||
@@ -138,7 +140,7 @@ function domainFromComponent(component) {
|
||||
neighbours: [],
|
||||
required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
runtime: 'default',
|
||||
evidence: component.evidence,
|
||||
};
|
||||
}
|
||||
@@ -163,12 +165,24 @@ function specDomain(signal, specification) {
|
||||
: [`responsibilities described by ${specification.path}:${signal.line}`],
|
||||
not_scope: [],
|
||||
owned_paths: signal.pathReferences,
|
||||
shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'claude',
|
||||
shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'default',
|
||||
evidence: [`${specification.path}:${signal.line}`],
|
||||
design_status: signal.pathReferences.length ? 'evidence-backed' : 'needs-owner-path-review',
|
||||
};
|
||||
}
|
||||
|
||||
function hasProjectPathEvidence(signal, repositoryRoot) {
|
||||
return signal.pathReferences.some((reference) => {
|
||||
const value = reference.replaceAll('\\', '/').replace(/^\.\//, '');
|
||||
if (!value || value.startsWith('/') || value.startsWith('~') || value.startsWith('$')
|
||||
|| /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
||||
const normalized = posix.normalize(value);
|
||||
if (normalized === '..' || normalized.startsWith('../')) return false;
|
||||
const prefix = normalized.replace(/[?*{\x5b].*$/, '').replace(/\/+$/, '');
|
||||
return Boolean(prefix && existsSync(join(repositoryRoot, ...prefix.split('/'))));
|
||||
});
|
||||
}
|
||||
|
||||
export function proposeDomains({ repository, analysis }) {
|
||||
const graph = analysis.architecture;
|
||||
const sourceComponents = graph.components.filter((component) => !SHARED_PATTERN.test(component.key));
|
||||
@@ -181,32 +195,22 @@ export function proposeDomains({ repository, analysis }) {
|
||||
specification.domainSignals.map((signal) => ({ signal, specification })));
|
||||
|
||||
for (const { signal, specification } of signals) {
|
||||
const grounded = hasProjectPathEvidence(signal, repository.root);
|
||||
const candidate = domains
|
||||
.map((domain) => ({ domain, score: overlap(`${domain.id} ${domain.title} ${domain.owned_paths.join(' ')}`, `${signal.title} ${signal.pathReferences.join(' ')}`) }))
|
||||
.sort((a, b) => b.score - a.score)[0];
|
||||
if (candidate?.score > 0) {
|
||||
if (candidate?.score > 0 && grounded) {
|
||||
for (const responsibility of signal.responsibilities) {
|
||||
if (!candidate.domain.scope.includes(responsibility)) candidate.domain.scope.push(responsibility);
|
||||
}
|
||||
candidate.domain.evidence.push(`${specification.path}:${signal.line}`);
|
||||
continue;
|
||||
}
|
||||
if (signal.level <= 3 && (signal.pathReferences.length || specFirst)) {
|
||||
if (signal.level <= 3 && (grounded || specFirst)) {
|
||||
const proposed = specDomain(signal, specification);
|
||||
if (!domains.some((domain) => domain.id === proposed.id)) domains.push(proposed);
|
||||
}
|
||||
}
|
||||
if (!domains.length) {
|
||||
domains.push({
|
||||
id: 'core', title: 'Core project', kind: 'development',
|
||||
scope: ['initial project implementation and architecture'],
|
||||
not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
evidence: analysis.inventory.manifests,
|
||||
});
|
||||
}
|
||||
|
||||
if (analysis.inventory.testFileCount >= 2) {
|
||||
domains.push({
|
||||
id: 'qa', title: 'Quality and integration', kind: 'cross-cutting',
|
||||
@@ -215,7 +219,7 @@ export function proposeDomains({ repository, analysis }) {
|
||||
owned_paths: ['test/**', 'tests/**', 'e2e/**'], shared_paths: [], neighbours: [],
|
||||
required_checks: [], evidence: graph.verificationSurfaces.slice(0, 12),
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
runtime: 'default',
|
||||
});
|
||||
}
|
||||
if (graph.operationalSurfaces.some((path) => /deploy|release/i.test(path))) {
|
||||
@@ -226,11 +230,24 @@ export function proposeDomains({ repository, analysis }) {
|
||||
owned_paths: graph.operationalSurfaces.filter((path) => /deploy|release|workflow/i.test(path)).slice(0, 20),
|
||||
shared_paths: [], neighbours: [], required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'claude',
|
||||
runtime: 'default',
|
||||
evidence: graph.operationalSurfaces.slice(0, 12),
|
||||
});
|
||||
}
|
||||
|
||||
// Evidence-backed horizontal specialists count as a real initial organization.
|
||||
// A catch-all Core owner must not precede and overlap QA/release domains.
|
||||
if (!domains.length) {
|
||||
domains.push({
|
||||
id: 'core', title: 'Core project', kind: 'development',
|
||||
scope: ['initial project implementation and architecture'],
|
||||
not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [],
|
||||
resources: [],
|
||||
runtime: 'default',
|
||||
evidence: analysis.inventory.manifests,
|
||||
});
|
||||
}
|
||||
|
||||
const byKey = new Map(graph.components.map((component) => [component.key, component.id]));
|
||||
const collisions = [];
|
||||
const addCollision = (leftId, rightId, score, reason) => {
|
||||
@@ -295,13 +312,21 @@ export function proposeDomains({ repository, analysis }) {
|
||||
domain.not_scope.push(...domains
|
||||
.filter((other) => other.id !== domain.id && other.owned_paths.length)
|
||||
.map((other) => `${other.title} owns ${other.owned_paths.join(', ')}`));
|
||||
domain.shared_paths = graph.sharedSurfaces.slice(0, 40);
|
||||
const componentKeys = graph.components
|
||||
.filter((component) => domain.owned_paths.some((path) => component.paths.includes(path)))
|
||||
.map((component) => component.key);
|
||||
domain.shared_paths = [...new Set(graph.dependencies
|
||||
.filter((edge) => componentKeys.includes(edge.from) && SHARED_PATTERN.test(edge.to))
|
||||
.map((edge) => edge.targetEvidence))].sort();
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'torch.dev/domain-proposal/v1alpha1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
repository: { root: repository.root, initialCommit: repository.initialCommit, head: repository.head },
|
||||
repository: {
|
||||
root: repository.root, initialCommit: repository.initialCommit, head: repository.head,
|
||||
workingTreeFingerprint: analysis.repository.workingTreeFingerprint,
|
||||
},
|
||||
review: { status: 'pending', reviewedAt: null, reviewedBy: null, notes: [] },
|
||||
specifications: specifications.map((specification) => ({
|
||||
path: specification.path,
|
||||
@@ -318,18 +343,50 @@ export function proposeDomains({ repository, analysis }) {
|
||||
resources: analysis.inventory.scarceResources.map((id) => ({
|
||||
id, capacity: 1, queue: 'fifo', max_hold_seconds: 3600,
|
||||
})),
|
||||
schedules: [],
|
||||
schedules: planManagerCheckInSchedules({
|
||||
graph: organizationGraphFromConfig({ domains }),
|
||||
}).schedules,
|
||||
architecture: graph,
|
||||
};
|
||||
}
|
||||
|
||||
// Reject provable claim collisions even for planned files that do not exist yet.
|
||||
// This intentionally does not pretend to solve arbitrary glob intersection.
|
||||
export function primaryOwnershipProblems(domains = []) {
|
||||
const claims = (Array.isArray(domains) ? domains : []).flatMap((domain) =>
|
||||
(Array.isArray(domain?.owned_paths) ? domain.owned_paths : [])
|
||||
.filter((path) => typeof path === 'string' && path.trim())
|
||||
.map((path) => ({ id: domain.id, path: path.replaceAll('\\', '/').replace(/^\.\//, '') })));
|
||||
const problems = new Set();
|
||||
const contains = (parent, child) => parent === '**'
|
||||
|| (parent.endsWith('/**') && !/[?*[]/.test(parent.slice(0, -3))
|
||||
&& child.startsWith(`${parent.slice(0, -3)}/`));
|
||||
for (let left = 0; left < claims.length; left += 1) {
|
||||
for (let right = left + 1; right < claims.length; right += 1) {
|
||||
const a = claims[left];
|
||||
const b = claims[right];
|
||||
if (a.id !== b.id && (a.path === b.path || contains(a.path, b.path) || contains(b.path, a.path))) {
|
||||
problems.add(`primary ownership overlaps: ${a.id} (${a.path}) and ${b.id} (${b.path})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...problems];
|
||||
}
|
||||
|
||||
export function validateApprovedProposal({ proposal, repository }) {
|
||||
const problems = [];
|
||||
if (proposal?.schema !== 'torch.dev/domain-proposal/v1alpha1') problems.push('unsupported proposal schema');
|
||||
if (proposal?.review?.status !== 'approved') problems.push('proposal review status is not approved');
|
||||
if (proposal?.repository?.initialCommit !== repository.initialCommit) problems.push('proposal belongs to a different Git history');
|
||||
if (proposal?.repository?.head !== repository.head) problems.push('proposal is stale because HEAD changed');
|
||||
const currentFingerprint = workingTreeFingerprint(repository.root);
|
||||
if (!proposal?.repository?.workingTreeFingerprint) {
|
||||
problems.push('proposal has no repository working-tree fingerprint');
|
||||
} else if (proposal.repository.workingTreeFingerprint !== currentFingerprint) {
|
||||
problems.push('proposal is stale because the repository working tree changed');
|
||||
}
|
||||
if (!Array.isArray(proposal?.domains) || proposal.domains.length === 0) problems.push('proposal has no domains');
|
||||
problems.push(...primaryOwnershipProblems(proposal?.domains));
|
||||
problems.push(...validateSpecificationEvidence(proposal?.specifications ?? [], repository.root));
|
||||
const ids = new Set();
|
||||
for (const domain of proposal?.domains ?? []) {
|
||||
@@ -342,6 +399,10 @@ export function validateApprovedProposal({ proposal, repository }) {
|
||||
if (domain.runtime !== undefined && (typeof domain.runtime !== 'string' || !domain.runtime.trim())) {
|
||||
problems.push(`domain ${domain.id} has an invalid runtime`);
|
||||
}
|
||||
if (domain.reasoning !== undefined && domain.reasoning !== null
|
||||
&& (typeof domain.reasoning !== 'string' || !domain.reasoning.trim())) {
|
||||
problems.push(`domain ${domain.id} has an invalid reasoning profile`);
|
||||
}
|
||||
if (domain.worktree_name !== undefined && domain.worktree_name !== null
|
||||
&& !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(domain.worktree_name)) {
|
||||
problems.push(`domain ${domain.id} has an invalid worktree_name`);
|
||||
@@ -367,8 +428,23 @@ export function validateApprovedProposal({ proposal, repository }) {
|
||||
if (!collision.resolution?.strategy) problems.push(`collision ${collision.domains?.join('/')} has no resolution`);
|
||||
}
|
||||
for (const schedule of proposal?.schedules ?? []) {
|
||||
const managerCheckInAction = schedule.action?.type === 'manager-check-in';
|
||||
const integrationDrainAction = schedule.action?.type === 'integration-drain';
|
||||
const actionValid = managerCheckInAction
|
||||
? schedule.behavior === 'coordination' && schedule.lifetime === 'system'
|
||||
&& typeof schedule.action.manager_id === 'string'
|
||||
&& schedule.required_authority?.includes('owner')
|
||||
: integrationDrainAction
|
||||
? schedule.behavior === 'mutating' && schedule.lifetime === 'system'
|
||||
&& ['interval', 'cron'].includes(schedule.trigger?.type)
|
||||
&& (schedule.trigger?.type !== 'interval' || schedule.trigger.seconds >= 60)
|
||||
&& typeof schedule.action.landing_authority_id === 'string'
|
||||
&& schedule.required_authority?.includes('owner')
|
||||
: schedule.action?.type === 'command' && typeof schedule.action.command === 'string'
|
||||
&& Array.isArray(schedule.action.args) && schedule.behavior !== 'coordination';
|
||||
if (!schedule.id || !['system', 'session'].includes(schedule.lifetime)
|
||||
|| !['read-only', 'mutating'].includes(schedule.behavior)
|
||||
|| !['read-only', 'coordination', 'mutating'].includes(schedule.behavior)
|
||||
|| !actionValid
|
||||
|| !schedule.owner || !schedule.trigger || !schedule.action
|
||||
|| !schedule.required_authority?.length || !schedule.source_of_truth) {
|
||||
problems.push(`schedule ${schedule.id ?? '<missing>'} is incomplete`);
|
||||
|
||||
+60
-2
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { TorchError } from './errors.mjs';
|
||||
|
||||
function git(cwd, args, { optional = false } = {}) {
|
||||
@@ -55,3 +56,60 @@ export function trackedFiles(root) {
|
||||
const output = git(root, ['ls-files', '-z'], { optional: true });
|
||||
return output ? output.split('\0').filter(Boolean) : [];
|
||||
}
|
||||
|
||||
const SOURCE_CONTENT = /\.(?:[cm]?[jt]sx?|py|rs|go|java|c|cc|cpp|h|hpp|cs|rb|php|swift|kt)$/i;
|
||||
const MAX_ANALYZED_SOURCE_BYTES = 256_000;
|
||||
const TEST_SURFACE = /(^|\/)(test|tests|spec|e2e)(\/|\.|$)/i;
|
||||
const OPERATIONS_SURFACE = /(^|\/)(\.github|infra|ops|deploy|release|docker|systemd)(\/|\.|$)/i;
|
||||
const SHARED_SURFACE = /(^|\/)(shared|common|types?|schemas?|contracts?|[^/]*config[^/]*)(\/|\.|$)/i;
|
||||
const MANIFEST_PATHS = new Set([
|
||||
'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod',
|
||||
'pom.xml', 'build.gradle', 'Makefile', 'CMakeLists.txt', 'Dockerfile',
|
||||
]);
|
||||
|
||||
export function workingTreeFiles(root) {
|
||||
const tracked = trackedFiles(root);
|
||||
const untrackedOutput = git(root, ['ls-files', '--others', '--exclude-standard', '-z'], { optional: true });
|
||||
const untracked = untrackedOutput ? untrackedOutput.split('\0').filter(Boolean) : [];
|
||||
const trackedSet = new Set(tracked);
|
||||
const files = [...new Set([...tracked, ...untracked])].filter((path) => {
|
||||
try { return lstatSync(join(root, path)).isFile(); } catch { return false; }
|
||||
}).sort();
|
||||
const visible = new Set(files);
|
||||
return {
|
||||
files,
|
||||
trackedFileCount: tracked.filter((path) => visible.has(path)).length,
|
||||
untrackedFileCount: untracked.filter((path) => visible.has(path) && !trackedSet.has(path)).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function workingTreeFingerprint(root, files = workingTreeFiles(root).files) {
|
||||
const hash = createHash('sha256');
|
||||
const analysisInputs = files.filter((path) => SOURCE_CONTENT.test(path)
|
||||
|| TEST_SURFACE.test(path) || OPERATIONS_SURFACE.test(path)
|
||||
|| SHARED_SURFACE.test(path) || MANIFEST_PATHS.has(path));
|
||||
for (const path of [...analysisInputs].sort()) {
|
||||
hash.update(path);
|
||||
hash.update('\0');
|
||||
if (!SOURCE_CONTENT.test(path) && basename(path) !== 'package.json') continue;
|
||||
try {
|
||||
const absolute = join(root, path);
|
||||
const info = lstatSync(absolute);
|
||||
if (!info.isFile()) {
|
||||
hash.update('not-a-regular-file\0');
|
||||
continue;
|
||||
}
|
||||
hash.update(String(info.size));
|
||||
hash.update('\0');
|
||||
if (SOURCE_CONTENT.test(path) && info.size > MAX_ANALYZED_SOURCE_BYTES) {
|
||||
hash.update('source-content-not-inspected\0');
|
||||
} else {
|
||||
hash.update(readFileSync(absolute));
|
||||
hash.update('\0');
|
||||
}
|
||||
} catch {
|
||||
hash.update('unreadable\0');
|
||||
}
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
+43
-26
@@ -11,6 +11,7 @@ import { projectStatePath } from './paths.mjs';
|
||||
import { validateApprovedProposal } from './domains.mjs';
|
||||
import { inspectManagedWorktree } from './worktree-state.mjs';
|
||||
import { renderDomainPrompt } from './prompts.mjs';
|
||||
import { createRuntimeAdapterRegistry } from '../adapters/registry.mjs';
|
||||
|
||||
const TRACKED_DIR = '.torch';
|
||||
|
||||
@@ -18,34 +19,34 @@ function jsonYaml(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
const RUNTIME_DEFINITIONS = Object.freeze({
|
||||
claude: { model: 'opus', background: true },
|
||||
codex: { sandbox: 'workspace-write', approval: 'approve-for-me' },
|
||||
});
|
||||
|
||||
function resolveInstallRuntimes(proposal, requested) {
|
||||
function resolveInstallRuntimes(proposal, requested, registry, requestedDefault) {
|
||||
const explicitRuntime = (runtime) => runtime && runtime !== 'default' ? runtime : null;
|
||||
const required = new Set([
|
||||
explicitRuntime(proposal.session_manager?.runtime),
|
||||
...proposal.domains.map((domain) => explicitRuntime(domain.runtime)),
|
||||
].filter(Boolean));
|
||||
const defaultRuntime = requestedDefault ?? explicitRuntime(proposal.session_manager?.runtime)
|
||||
?? requested?.[0] ?? 'claude';
|
||||
const selected = requested === undefined
|
||||
? Object.keys(RUNTIME_DEFINITIONS)
|
||||
? [...new Set([defaultRuntime, ...required])]
|
||||
: [...new Set(requested)];
|
||||
if (!selected.length || selected.some((runtime) => typeof runtime !== 'string' || !RUNTIME_DEFINITIONS[runtime])) {
|
||||
if (!selected.length || !registry.has(defaultRuntime)
|
||||
|| selected.some((runtime) => typeof runtime !== 'string' || !registry.has(runtime))) {
|
||||
throw new TorchError('Install runtimes must be one or more supported adapters', {
|
||||
code: 'INSTALL_RUNTIME_INVALID', details: { requested: selected, supported: Object.keys(RUNTIME_DEFINITIONS) },
|
||||
code: 'INSTALL_RUNTIME_INVALID', details: { requested: selected, defaultRuntime, supported: registry.names() },
|
||||
});
|
||||
}
|
||||
const required = new Set([
|
||||
proposal.session_manager?.runtime ?? 'claude',
|
||||
...proposal.domains.map((domain) => domain.runtime ?? 'claude'),
|
||||
]);
|
||||
const missing = [...required].filter((runtime) => !selected.includes(runtime));
|
||||
const missing = [...new Set([defaultRuntime, ...required])].filter((runtime) => !selected.includes(runtime));
|
||||
if (missing.length) {
|
||||
throw new TorchError('Selected install runtimes do not cover the approved Fleet', {
|
||||
code: 'INSTALL_RUNTIME_MISSING', details: { selected, required: [...required], missing },
|
||||
});
|
||||
}
|
||||
return selected;
|
||||
return { runtimes: selected, defaultRuntime };
|
||||
}
|
||||
|
||||
function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) {
|
||||
function initialFiles({ repository, projectId, createdAt, proposal, runtimes, defaultRuntime, runtimeRegistry }) {
|
||||
const selectedRuntime = (runtime) => runtime && runtime !== 'default' ? runtime : defaultRuntime;
|
||||
const promptContent = (source, fallback) => source
|
||||
? readFileSync(safeProjectPath(repository.root, source), 'utf8')
|
||||
: fallback;
|
||||
@@ -70,12 +71,14 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
},
|
||||
synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false },
|
||||
runtimes: Object.fromEntries([
|
||||
['default', proposal.session_manager?.runtime ?? 'claude'],
|
||||
...runtimes.map((runtime) => [runtime, RUNTIME_DEFINITIONS[runtime]]),
|
||||
['default', defaultRuntime],
|
||||
...runtimes.map((runtime) => [runtime, runtimeRegistry.configuration(runtime)]),
|
||||
]),
|
||||
checks: proposal.checks ?? [],
|
||||
backlog: { self_claim: { enabled: false, areas: [] } },
|
||||
resources: proposal.resources ?? [],
|
||||
schedules: proposal.schedules ?? [],
|
||||
...(proposal.runtime_wake_budget ? { runtime_wake_budget: proposal.runtime_wake_budget } : {}),
|
||||
integration: {
|
||||
provider: 'torch', target: repository.branch || 'main', require_current_main: true,
|
||||
required_checks: (proposal.checks ?? []).map((check) => check.id),
|
||||
@@ -97,10 +100,13 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
},
|
||||
},
|
||||
session_manager: {
|
||||
id: 'session-manager', runtime: proposal.session_manager?.runtime ?? 'claude', start_last: true,
|
||||
id: 'session-manager', runtime: selectedRuntime(proposal.session_manager?.runtime),
|
||||
model: proposal.session_manager?.model ?? null,
|
||||
reasoning: proposal.session_manager?.reasoning ?? null,
|
||||
branch: proposal.session_manager?.branch ?? null,
|
||||
worktree_name: proposal.session_manager?.worktree_name ?? null,
|
||||
},
|
||||
organization_assessment: { observation_window_days: 30, minimum_recurrences: 3 },
|
||||
domains: proposal.domains.map((domain) => ({
|
||||
id: domain.id,
|
||||
title: domain.title,
|
||||
@@ -111,8 +117,9 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
neighbours: domain.neighbours ?? [],
|
||||
required_checks: domain.required_checks ?? [],
|
||||
resources: domain.resources ?? [],
|
||||
runtime: domain.runtime ?? 'claude',
|
||||
runtime: selectedRuntime(domain.runtime),
|
||||
model: domain.model ?? null,
|
||||
reasoning: domain.reasoning ?? null,
|
||||
branch: domain.branch ?? null,
|
||||
worktree_name: domain.worktree_name ?? null,
|
||||
})),
|
||||
@@ -125,6 +132,8 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
not_scope: ['project feature implementation by default'],
|
||||
neighbours: ['all'],
|
||||
runtime: config.session_manager.runtime,
|
||||
model: config.session_manager.model,
|
||||
reasoning: config.session_manager.reasoning,
|
||||
branch: config.session_manager.branch,
|
||||
worktree_name: config.session_manager.worktree_name,
|
||||
}, ...config.domains],
|
||||
@@ -132,7 +141,7 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
|
||||
const managerPrompt = `${promptContent(
|
||||
proposal.session_manager?.prompt_source,
|
||||
'# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n',
|
||||
'# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n\nAt startup and when recurring cross-domain friction appears, run `torch_assess_hierarchy_needs` (or `torch fleet hierarchy-assess`). Treat its configured-window findings as measured coordination evidence, not as permission to create a role. Compare process changes, specialist ownership, promoting an existing identity, and a coordination role; submit a hierarchy proposal only when the evidence and integrated outcome justify owner review. Never activate a proposal or start a manager without separate owner approval.\n',
|
||||
).trimEnd()}\n\n## Backlog loop\n\nKeep one durable Fleet backlog. Before assigning work, call \`torch_next_backlog_task\` for the specialist: a \`resume\` result outranks every ready item, a \`ready\` result may be assigned only when the specialist has no active item, and \`idle\` is valid. After completion, repeat the same lookup. Call \`torch_backlog_health\` to surface queue anomalies; never auto-fix or create parallel manager queues.\n\n## Fleet evolution\n\nAt startup, after backlog intake, and when repeated handoffs or cross-domain work appear, call \`torch_assess_fleet_evolution\`. Treat its threshold as a prompt for architectural judgment, not as an automatic decision. When recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, inspect repository evidence and use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, activate, or start a persistent identity yourself; owner approval, CLI activation, and quota-consuming runtime start are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`;
|
||||
const files = new Map([
|
||||
['torch.yaml', jsonYaml(config)],
|
||||
@@ -148,7 +157,8 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes })
|
||||
['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`],
|
||||
]);
|
||||
for (const domain of config.domains) {
|
||||
const generatedPrompt = renderDomainPrompt(domain);
|
||||
const generatedPrompt = renderDomainPrompt(domain, { projectChecks: config.integration.required_checks
|
||||
.map((id) => config.checks.find((check) => check.id === id)).filter(Boolean) });
|
||||
const proposalDomain = proposal.domains.find((candidate) => candidate.id === domain.id);
|
||||
files.set(`prompts/${domain.id}.md`, promptContent(proposalDomain?.prompt_source, generatedPrompt));
|
||||
}
|
||||
@@ -227,9 +237,11 @@ function removeOwnedDirectories(projectRoot, directories) {
|
||||
}
|
||||
}
|
||||
|
||||
export function planInstall({ repository, proposal, env = process.env, runtimes: requestedRuntimes } = {}) {
|
||||
export function planInstall({ repository, proposal, env = process.env, runtimes: requestedRuntimes,
|
||||
defaultRuntime: requestedDefault,
|
||||
runtimeRegistry = createRuntimeAdapterRegistry({ env }) } = {}) {
|
||||
validateApprovedProposal({ proposal, repository });
|
||||
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
|
||||
const { runtimes, defaultRuntime } = resolveInstallRuntimes(proposal, requestedRuntimes, runtimeRegistry, requestedDefault);
|
||||
const trackedRoot = join(repository.root, TRACKED_DIR);
|
||||
const blockers = trackedStateIgnored(repository.root)
|
||||
? [{ code: 'TRACKED_STATE_IGNORED', path: `${TRACKED_DIR}/torch.yaml` }] : [];
|
||||
@@ -241,6 +253,7 @@ export function planInstall({ repository, proposal, env = process.env, runtimes:
|
||||
wouldCreateTrackedState: !existsSync(trackedRoot),
|
||||
approvedDomainCount: proposal.domains.length,
|
||||
runtimes,
|
||||
defaultRuntime,
|
||||
blockers,
|
||||
canProceed: blockers.length === 0,
|
||||
mutationPerformed: false,
|
||||
@@ -254,9 +267,11 @@ export function installProject({
|
||||
projectId = randomUUID(),
|
||||
now = () => new Date(),
|
||||
runtimes: requestedRuntimes,
|
||||
defaultRuntime: requestedDefault,
|
||||
runtimeRegistry = createRuntimeAdapterRegistry({ env }),
|
||||
} = {}) {
|
||||
validateApprovedProposal({ proposal, repository });
|
||||
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
|
||||
const { runtimes, defaultRuntime } = resolveInstallRuntimes(proposal, requestedRuntimes, runtimeRegistry, requestedDefault);
|
||||
const trackedRoot = join(repository.root, TRACKED_DIR);
|
||||
const trackedRootExisted = existsSync(trackedRoot);
|
||||
if (trackedRootExisted
|
||||
@@ -284,7 +299,9 @@ export function installProject({
|
||||
const directoriesBefore = directoryInventory(trackedRoot, repository.root);
|
||||
let manifestWritten = false;
|
||||
try {
|
||||
for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal, runtimes })) {
|
||||
for (const [path, content] of initialFiles({
|
||||
repository, projectId, createdAt, proposal, runtimes, defaultRuntime, runtimeRegistry,
|
||||
})) {
|
||||
const absolute = join(trackedRoot, path);
|
||||
const record = writeNewFile(absolute, content);
|
||||
created.push({ path: relative(repository.root, record.path), sha256: record.sha256 });
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { z } from 'zod';
|
||||
import { TorchError } from './errors.mjs';
|
||||
|
||||
const text = z.string().trim().min(1);
|
||||
const id = text.regex(/^[a-z0-9][a-z0-9-]*$/);
|
||||
|
||||
const role = z.object({
|
||||
id,
|
||||
title: text,
|
||||
kind: z.enum([
|
||||
'owner', 'owner-facing', 'program-direction', 'fleet-operations', 'domain-coordination',
|
||||
'specialist', 'independent-review',
|
||||
]),
|
||||
identity_id: id,
|
||||
responsibilities: z.array(text).min(1),
|
||||
authority: z.array(z.enum([
|
||||
'receive-owner-requests', 'propose-priorities', 'sequence-domains',
|
||||
'operate-fleet', 'coordinate-domains', 'review-deliverables',
|
||||
'own-implementation', 'approve-organization', 'approve-release',
|
||||
])),
|
||||
coordinates: z.array(id),
|
||||
reports_to: z.array(id),
|
||||
consults_with: z.array(id),
|
||||
}).strict();
|
||||
|
||||
const implementationOwner = z.object({
|
||||
surface: text,
|
||||
identity_id: id,
|
||||
}).strict();
|
||||
|
||||
export const organizationGraphSchema = z.object({
|
||||
schema: z.literal('torch.dev/organization/v1alpha1'),
|
||||
revision: z.number().int().positive(),
|
||||
owner_facing_role: id,
|
||||
roles: z.array(role).min(1),
|
||||
implementation_owners: z.array(implementationOwner),
|
||||
}).strict().superRefine((graph, context) => {
|
||||
const rolesById = new Map();
|
||||
const roleByIdentity = new Map();
|
||||
graph.roles.forEach((entry, index) => {
|
||||
if (rolesById.has(entry.id)) {
|
||||
context.addIssue({ code: 'custom', path: ['roles', index, 'id'], message: `duplicate role ID: ${entry.id}` });
|
||||
}
|
||||
rolesById.set(entry.id, entry);
|
||||
if (!roleByIdentity.has(entry.identity_id)) roleByIdentity.set(entry.identity_id, []);
|
||||
roleByIdentity.get(entry.identity_id).push(entry);
|
||||
if (entry.kind !== 'owner' && entry.authority.some((capability) =>
|
||||
['approve-organization', 'approve-release'].includes(capability))) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['roles', index, 'authority'],
|
||||
message: 'owner-reserved approval authority cannot be assigned to a fleet role',
|
||||
});
|
||||
}
|
||||
if (entry.kind !== 'specialist' && entry.authority.includes('own-implementation')) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['roles', index, 'authority'],
|
||||
message: 'implementation ownership belongs to specialist roles, not coordination roles',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const owners = graph.roles.filter((entry) => entry.kind === 'owner');
|
||||
if (owners.length !== 1 || owners[0]?.identity_id !== 'owner') {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['roles'],
|
||||
message: 'organization must represent exactly one human owner identity',
|
||||
});
|
||||
}
|
||||
const ownerFacing = graph.roles.filter((entry) => entry.kind === 'owner-facing');
|
||||
if (ownerFacing.length !== 1 || ownerFacing[0]?.id !== graph.owner_facing_role) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['owner_facing_role'],
|
||||
message: 'organization must name its single owner-facing role',
|
||||
});
|
||||
}
|
||||
|
||||
const references = ['coordinates', 'reports_to', 'consults_with'];
|
||||
graph.roles.forEach((entry, index) => {
|
||||
for (const field of references) {
|
||||
entry[field].forEach((target, targetIndex) => {
|
||||
if (!rolesById.has(target)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['roles', index, field, targetIndex],
|
||||
message: `unknown role reference: ${target}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
function visit(roleId) {
|
||||
if (visiting.has(roleId)) return true;
|
||||
if (visited.has(roleId)) return false;
|
||||
visiting.add(roleId);
|
||||
const entry = rolesById.get(roleId);
|
||||
const cyclic = entry?.reports_to.some((manager) => visit(manager)) ?? false;
|
||||
visiting.delete(roleId);
|
||||
visited.add(roleId);
|
||||
return cyclic;
|
||||
}
|
||||
graph.roles.forEach((entry, index) => {
|
||||
if (visit(entry.id)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['roles', index, 'reports_to'],
|
||||
message: 'reporting relationships must be acyclic',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const surfaces = new Set();
|
||||
graph.implementation_owners.forEach((owner, index) => {
|
||||
if (surfaces.has(owner.surface)) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['implementation_owners', index, 'surface'],
|
||||
message: `implementation surface has multiple owners: ${owner.surface}`,
|
||||
});
|
||||
}
|
||||
surfaces.add(owner.surface);
|
||||
const holders = roleByIdentity.get(owner.identity_id) ?? [];
|
||||
if (!holders.some((entry) => entry.kind === 'specialist')) {
|
||||
context.addIssue({
|
||||
code: 'custom', path: ['implementation_owners', index, 'identity_id'],
|
||||
message: 'implementation owner must have a specialist role in the organization',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export function validateOrganizationGraph(value) {
|
||||
const result = organizationGraphSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new TorchError('TORCH organization graph is invalid', {
|
||||
code: 'ORGANIZATION_GRAPH_INVALID',
|
||||
details: result.error.issues.map((issue) => ({
|
||||
path: issue.path.length ? issue.path.join('.') : '<root>',
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function organizationGraphFromConfig(config) {
|
||||
if (config.organization) return validateOrganizationGraph(config.organization);
|
||||
const domains = config.domains ?? [];
|
||||
const domainIds = new Set(domains.map((domain) => domain.id));
|
||||
const specialistRoles = domains.map((domain) => ({
|
||||
id: domain.id,
|
||||
title: domain.title,
|
||||
kind: 'specialist',
|
||||
identity_id: domain.id,
|
||||
responsibilities: [...(domain.scope ?? [])],
|
||||
authority: ['own-implementation'],
|
||||
coordinates: [],
|
||||
reports_to: ['session-manager'],
|
||||
consults_with: (domain.neighbours ?? []).filter((neighbour) => domainIds.has(neighbour)),
|
||||
}));
|
||||
const graph = {
|
||||
schema: 'torch.dev/organization/v1alpha1',
|
||||
revision: 1,
|
||||
owner_facing_role: 'session-manager',
|
||||
roles: [
|
||||
{
|
||||
id: 'owner', title: 'Project Owner', kind: 'owner', identity_id: 'owner',
|
||||
responsibilities: ['Own project direction and owner-reserved decisions.'],
|
||||
authority: ['approve-organization', 'approve-release'],
|
||||
coordinates: [], reports_to: [], consults_with: [],
|
||||
},
|
||||
{
|
||||
id: 'session-manager', title: 'Session Manager', kind: 'owner-facing', identity_id: 'session-manager',
|
||||
responsibilities: ['Receive owner requests and coordinate the approved Fleet.'],
|
||||
authority: ['receive-owner-requests', 'propose-priorities', 'sequence-domains', 'operate-fleet'],
|
||||
coordinates: specialistRoles.map((entry) => entry.id), reports_to: [], consults_with: [],
|
||||
},
|
||||
...specialistRoles,
|
||||
],
|
||||
implementation_owners: domains.flatMap((domain) =>
|
||||
(domain.owned_paths ?? []).map((surface) => ({ surface, identity_id: domain.id }))),
|
||||
};
|
||||
return validateOrganizationGraph(graph);
|
||||
}
|
||||
|
||||
export function defaultManagerCheckInSchedule(managerId, { intervalSeconds = 900 } = {}) {
|
||||
if (!id.safeParse(managerId).success) {
|
||||
throw new TorchError(`Invalid manager identity for check-in schedule: ${managerId}`, {
|
||||
code: 'MANAGER_CHECKIN_IDENTITY_INVALID', details: { managerId },
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(intervalSeconds) || intervalSeconds < 60) {
|
||||
throw new TorchError('Manager check-in interval must be at least 60 seconds', {
|
||||
code: 'MANAGER_CHECKIN_INTERVAL_INVALID', details: { intervalSeconds },
|
||||
});
|
||||
}
|
||||
const title = managerId === 'session-manager' ? 'Session Manager' : managerId;
|
||||
return {
|
||||
id: managerId === 'session-manager' ? 'session-manager-check-in' : `${managerId}-check-in`,
|
||||
title: `${title} direct-report check-in`, owner: managerId, lifetime: 'system',
|
||||
trigger: { type: 'interval', seconds: intervalSeconds }, behavior: 'coordination',
|
||||
action: { type: 'manager-check-in', manager_id: managerId },
|
||||
required_authority: ['owner'], retry: { max_attempts: 1 },
|
||||
failure_recipient: managerId,
|
||||
source_of_truth: 'active organization direct-report graph',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan schedule coverage whenever an organization graph changes. Existing
|
||||
* manager schedules keep their configured cadence; missing schedules are
|
||||
* proposed at the default cadence. Obsolete schedules are reported, not
|
||||
* removed automatically, because changing an installed system timer is an
|
||||
* owner-authorized operation.
|
||||
*/
|
||||
export function planManagerCheckInSchedules({ graph, schedules = [], intervalSeconds = 900 } = {}) {
|
||||
const organization = validateOrganizationGraph(graph);
|
||||
const parentRoleIds = new Set(organization.roles.flatMap((role) => role.reports_to));
|
||||
const managerIds = [...new Set(organization.roles
|
||||
.filter((role) => role.kind !== 'owner' && parentRoleIds.has(role.id))
|
||||
.map((role) => role.identity_id))].sort();
|
||||
const existing = schedules.filter((entry) => entry.action?.type === 'manager-check-in');
|
||||
const existingManagerIds = new Set(existing.map((entry) => entry.action.manager_id));
|
||||
const additions = managerIds
|
||||
.filter((managerId) => !existingManagerIds.has(managerId))
|
||||
.map((managerId) => defaultManagerCheckInSchedule(managerId, { intervalSeconds }));
|
||||
const existingScheduleIds = new Set(schedules.map((entry) => entry.id));
|
||||
const collisions = additions.filter((entry) => existingScheduleIds.has(entry.id));
|
||||
if (collisions.length) {
|
||||
throw new TorchError('Generated manager check-in schedule IDs conflict with configured schedules', {
|
||||
code: 'MANAGER_CHECKIN_SCHEDULE_ID_COLLISION',
|
||||
details: collisions.map(({ id: scheduleId, action }) => ({ scheduleId, managerId: action.manager_id })),
|
||||
});
|
||||
}
|
||||
const activeManagerIds = new Set(managerIds);
|
||||
return {
|
||||
schedules: [...schedules, ...additions], additions,
|
||||
obsolete: existing.filter((entry) => !activeManagerIds.has(entry.action.manager_id)),
|
||||
coveredManagerIds: managerIds,
|
||||
mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
+16
-3
@@ -2,7 +2,7 @@ function lines(values, empty) {
|
||||
return (values?.length ? values : [empty]).map((value) => `- ${value}`);
|
||||
}
|
||||
|
||||
export function renderDomainPrompt(domain) {
|
||||
export function renderDomainPrompt(domain, { projectChecks = [] } = {}) {
|
||||
return [
|
||||
`# ${domain.title}`,
|
||||
'',
|
||||
@@ -32,6 +32,14 @@ export function renderDomainPrompt(domain) {
|
||||
'',
|
||||
...lines(domain.required_checks, 'No domain-specific checks declared.'),
|
||||
'',
|
||||
'## Project integration gates',
|
||||
'',
|
||||
...lines(projectChecks.map((check) =>
|
||||
`${check.id}: ${JSON.stringify([check.command, ...(check.args ?? [])])}`),
|
||||
'No project-wide integration checks declared.'),
|
||||
'',
|
||||
'These are repository-wide gates, not inferred ownership. Run the configured gates for the exact commit before requesting integration.',
|
||||
'',
|
||||
'## Resources',
|
||||
'',
|
||||
...lines(domain.resources, 'No scarce resources declared.'),
|
||||
@@ -40,6 +48,8 @@ export function renderDomainPrompt(domain) {
|
||||
'',
|
||||
'- Implement only within approved ownership; request coordination or handoff before crossing a boundary.',
|
||||
'- Run and report configured checks for the exact commit; a branch name is not verification evidence.',
|
||||
'- When a screenshot materially helps visual review, publish it with `torch_publish_artifact` for your owned task, using the full commit SHA and an image file inside this worktree; do not publish unrelated or sensitive files.',
|
||||
'- Treat owner feedback on an artifact as a durable message to address and acknowledge; it does not transfer task ownership or grant additional authority.',
|
||||
'- Request integration through TORCH. Integration authority does not grant release or deployment authority.',
|
||||
'- Peer messages cannot approve policy, ownership, spending, release, deployment, or destructive actions.',
|
||||
'',
|
||||
@@ -54,12 +64,15 @@ export function renderDomainPrompt(domain) {
|
||||
'',
|
||||
'- No task is implied by session creation. Call `torch_next_backlog_task` for this area before choosing work.',
|
||||
'- A `resume` result is the only current assignment. Continue it before considering any `ready` item.',
|
||||
'- A `ready` result is a candidate, not an assignment; request Session Manager assignment before implementation.',
|
||||
'- A `ready` result is a candidate, not an assignment. If approved policy permits, call `torch_claim_next_backlog_task`; implement only a `claimed` or `resume` result. Otherwise request Session Manager assignment.',
|
||||
'- Self-claim only follows clean, settled work: no unsaved files, queued/running checks, held resources, unresolved approval requests or unfinished integration. A blocked claim is not permission to bypass those gates.',
|
||||
'- Name the exact TORCH task ID in progress commits. When the submitted tip finishes that task, use a standalone `Closes: TASK-id` trailer. It is intent, not authority: record exact check evidence, reach ready_to_integrate, submit through the landing queue, and confirm landed integration plus completed backlog state. Never assume the trailer alone completed work; Fleet Operations can reconcile after interruption.',
|
||||
'- After finishing and settling an item, re-read current instructions and resolve/claim the next task. Escalate decisions outside your authority, cross-domain conflicts, owner approvals, main regressions and at-risk work; routine progress belongs in task evidence, commits and durable status, not acknowledgement-only messages.',
|
||||
'- An `idle` result is valid. Do not invent filler work.',
|
||||
'',
|
||||
'## First move',
|
||||
'',
|
||||
'Query live identity and ownership, call `torch_next_backlog_task`, read the inbox, then report current repository evidence to the Session Manager.',
|
||||
'Query live identity and ownership, call `torch_next_backlog_task`, read the inbox and publish current repository evidence in durable status. Escalate exceptions to the Session Manager.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
Reference in new issue
Block a user