diff --git a/.cspell.json b/.cspell.json index 9a51454..6e8f804 100644 --- a/.cspell.json +++ b/.cspell.json @@ -51,6 +51,7 @@ "staticlib", "stry", "tctl", + "vkozlov", "tmpdirs", "toplevel", "Автовибір", @@ -94,6 +95,8 @@ "логується", "макроса", "машинно", + "метаключ", + "метаключі", "монтирує", "напр", "автовизначити", @@ -153,6 +156,10 @@ "рендериться", "рендером", "самозатиралися", + "скоуп", + "скоупи", + "скоупу", + "скоупом", "сконфігуровазованого", "спавнені", "таймаут", diff --git a/app/.changes/260715-1655.md b/app/.changes/260715-1655.md new file mode 100644 index 0000000..5dd7e63 --- /dev/null +++ b/app/.changes/260715-1655.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Changed +--- +Освіжено файлову доку `NodeActions.md` (CRC-дрейф, без змін поведінки). diff --git a/owner/.changes/260715-1600.md b/owner/.changes/260715-1600.md new file mode 100644 index 0000000..031fe44 --- /dev/null +++ b/owner/.changes/260715-1600.md @@ -0,0 +1,5 @@ +--- +bump: minor +section: Added +--- +M5 — скоуп власника (спека docs/specs/260714-cognitive-delegation.md): ідентичність-handle (whoami/set_identity), owner: в autonomy.yml, scan_owners, scope-фільтр черги/особистих задач/дельти, «нічия земля», fail-closed write-tools поза скоупом, крок «Хто ти» в онбордингу diff --git a/owner/src-tauri/src/config.rs b/owner/src-tauri/src/config.rs index 7b6ffcc..86d734d 100644 --- a/owner/src-tauri/src/config.rs +++ b/owner/src-tauri/src/config.rs @@ -1,8 +1,11 @@ -//! Конфіг owner-застосунку — project search paths. +//! Конфіг owner-застосунку — project search paths та ідентичність власника. //! -//! Зберігається у `appLocalDataDir/config.json` (`{ "project_paths": [...] }`). -//! Fallback-ланцюжок читання: власний конфіг → конфіг app (`com.nitra.task`, -//! щоб обидва застосунки бачили той самий ліс без налаштування) → `~/www`. +//! Зберігається у `appLocalDataDir/config.json` +//! (`{ "project_paths": [...], "identity": "handle" }`). +//! Fallback-ланцюжок читання шляхів: власний конфіг → конфіг app +//! (`com.nitra.task`, щоб обидва застосунки бачили той самий ліс без +//! налаштування) → `~/www`. Ідентичність — handle (як `assignee` у `h.md`, +//! PII лишається поза git) — тільки з власного конфігу, без fallback. use std::fs; use std::path::{Path, PathBuf}; @@ -47,17 +50,46 @@ pub fn get_project_paths() -> Vec { .unwrap_or_default() } -/// Зберігає project paths у власний конфіг owner. -pub fn set_project_paths(paths: Vec) -> Result<(), String> { +/// Читає весь власний конфіг (відсутній/битий файл — порожній обʼєкт). +fn read_own_config() -> Value { + own_config_path() + .ok() + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| json!({})) +} + +/// Мерджить один ключ у власний конфіг, зберігаючи решту ключів. +fn merge_own_config(key: &str, value: Value) -> Result<(), String> { let path = own_config_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let body = serde_json::to_string_pretty(&json!({ "project_paths": paths })) - .map_err(|e| e.to_string())?; + let mut config = read_own_config(); + config + .as_object_mut() + .ok_or("config.json: корінь не обʼєкт")? + .insert(key.to_string(), value); + let body = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; fs::write(path, body).map_err(|e| e.to_string()) } +/// Зберігає project paths у власний конфіг owner (інші ключі не чіпає). +pub fn set_project_paths(paths: Vec) -> Result<(), String> { + merge_own_config("project_paths", json!(paths)) +} + +/// Handle власника з конфігу (None — ідентичність ще не налаштована). +pub fn get_identity() -> Option { + let handle = read_own_config().get("identity")?.as_str()?.to_string(); + (!handle.is_empty()).then_some(handle) +} + +/// Зберігає handle власника (порожній — скидає ідентичність). +pub fn set_identity(handle: String) -> Result<(), String> { + merge_own_config("identity", json!(handle.trim())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/owner/src-tauri/src/docs/config.md b/owner/src-tauri/src/docs/config.md index 03e5dcc..3fa24fa 100644 --- a/owner/src-tauri/src/docs/config.md +++ b/owner/src-tauri/src/docs/config.md @@ -3,9 +3,8 @@ type: Rust Module title: config.rs resource: owner/src-tauri/src/config.rs docgen: - crc: b075356c + crc: 9ab105c1 model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min-retry score: 85 issues: anchor-miss:absent.json,anchor-miss:empty.json,anchor-miss:full.json,best-of-2:retry-won,judge:inaccurate:0.98 judgeModel: openai-codex/gpt-5.4-mini diff --git a/owner/src-tauri/src/docs/lib.md b/owner/src-tauri/src/docs/lib.md index 9750396..8f2d62b 100644 --- a/owner/src-tauri/src/docs/lib.md +++ b/owner/src-tauri/src/docs/lib.md @@ -3,7 +3,7 @@ type: Rust Module title: lib.rs resource: owner/src-tauri/src/lib.rs docgen: - crc: b9192bb9 + crc: 54d77e94 model: openai-codex/gpt-5.4-mini score: 100 issues: judge:inaccurate:0.99 diff --git a/owner/src-tauri/src/lib.rs b/owner/src-tauri/src/lib.rs index 7cab7f7..9034794 100644 --- a/owner/src-tauri/src/lib.rs +++ b/owner/src-tauri/src/lib.rs @@ -45,30 +45,159 @@ fn create_task(tasks_dir: String, name: String, opts: CreateOpts) -> Result Result<(), String> { for line in yaml.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } - let (key, gate) = line + let (key, value) = line .split_once(':') .ok_or_else(|| format!("autonomy.yml: невалідний рядок {line:?}"))?; - let gate = gate.trim(); - if gate != "auto" && gate != "approve" { + let (key, value) = (key.trim(), value.trim()); + if RESERVED_KEYS.contains(&key) { + if value.is_empty() || value.split_whitespace().count() != 1 { + return Err(format!( + "autonomy.yml: {key} — має бути один токен без пробілів, отримано {value:?}" + )); + } + continue; + } + if value != "auto" && value != "approve" { return Err(format!( - "autonomy.yml: клас {} — невідомий гейт {gate:?}", - key.trim() + "autonomy.yml: клас {key} — невідомий гейт {value:?}" )); } } Ok(()) } +/// Handle власника з тексту `autonomy.yml` (None — рядка `owner:` немає). +fn parse_owner(yaml: &str) -> Option { + for line in yaml.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once(':') { + if key.trim() == "owner" && !value.trim().is_empty() { + return Some(value.trim().to_string()); + } + } + } + None +} + +/// Рекурсивно збирає `owner:`-розмітку лісу: тека вузла (містить task.md) +/// з autonomy.yml → запис `шлях вузла → handle`. Приховані теки +/// (`.worktrees` тощо) пропускаються. +fn collect_owners(root: &Path, dir: &Path, owners: &mut std::collections::HashMap) { + for entry in fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if !path.is_dir() + || path + .file_name() + .and_then(|n| n.to_str()) + .is_none_or(|n| n.starts_with('.')) + { + continue; + } + if path.join("task.md").is_file() { + if let Ok(yaml) = fs::read_to_string(path.join("autonomy.yml")) { + if let Some(owner) = parse_owner(&yaml) { + if let Ok(rel) = path.strip_prefix(root) { + owners.insert(rel.to_string_lossy().replace('\\', "/"), owner); + } + } + } + } + collect_owners(root, &path, owners); + } +} + +/// `owner:`-розмітка воркспейсу одним проходом ФС — сировина scope-деривації +/// на фронті (порожня мапа = нерозмічений ліс, single-owner поведінка). +#[tauri::command] +fn scan_owners(tasks_dir: String) -> Result, String> { + let root = PathBuf::from(&tasks_dir); + if !root.is_dir() { + return Err(format!("tasks dir not found: {tasks_dir}")); + } + let mut owners = std::collections::HashMap::new(); + collect_owners(&root, &root, &mut owners); + Ok(owners) +} + +/// Handle власника застосунку (None — «Хто ти» ще не пройдено). +#[tauri::command] +fn get_identity() -> Option { + config::get_identity() +} + +/// Зберігає handle власника у локальний конфіг (PII лишається поза git). +#[tauri::command] +fn set_identity(handle: String) -> Result<(), String> { + config::set_identity(handle) +} + +/// Ефективний власник вузла з розмітки: найдовший префікс шляху, +/// що має `owner:` (фрактальне успадкування за графом задач). +fn effective_owner_of<'a>( + owners: &'a std::collections::HashMap, + task_path: &str, +) -> Option<&'a str> { + let segments: Vec<&str> = task_path.split('/').collect(); + (1..=segments.len()) + .rev() + .find_map(|i| owners.get(&segments[..i].join("/")).map(String::as_str)) +} + +/// Fail-closed гейт власності write-дій (чиста логіка, M5): у розміченому +/// лісі діяти на вузлі може лише його effective owner; вузол без власника — +/// «нічия земля», діяти може будь-хто (інакше осиротіла гілка блокується +/// назавжди). Нерозмічений ліс — single-owner поведінка без перевірки. +fn check_scope( + owners: &std::collections::HashMap, + task_path: &str, + me: Option<&str>, +) -> Result<(), String> { + if owners.is_empty() { + return Ok(()); + } + let Some(owner) = effective_owner_of(owners, task_path) else { + return Ok(()); + }; + let me = me.ok_or( + "ліс розмічений власниками, а твоя ідентичність не налаштована — виконай set_identity", + )?; + if owner == me { + Ok(()) + } else { + Err(format!( + "вузол {task_path} належить {owner} — поза твоїм скоупом (ескалація/делегування — M6)" + )) + } +} + +/// Гейт власності для tauri-команд: розмітка читається з ФС, ідентичність — +/// з конфігу застосунку. +fn assert_owned(tasks_dir: &str, task_path: &str) -> Result<(), String> { + let owners = scan_owners(tasks_dir.to_string())?; + check_scope(&owners, task_path, config::get_identity().as_deref()) +} + /// Власна політика вузла (порожній рядок — файлу немає, повне успадкування). #[tauri::command] fn read_autonomy(tasks_dir: String, task_path: String) -> Result { @@ -89,6 +218,7 @@ fn write_autonomy(tasks_dir: String, task_path: String, yaml: String) -> Result< return Err(format!("node not found: {task_path}")); } validate_autonomy(&yaml)?; + assert_owned(&tasks_dir, &task_path)?; fs::write(dir.join("autonomy.yml"), yaml).map_err(|e| e.to_string()) } @@ -128,6 +258,7 @@ fn draft_plan( if !dir.join("task.md").is_file() { return Err(format!("node not found: {task_path}")); } + assert_owned(&tasks_dir, &task_path)?; let children = mt_core::spawn::parse_children(&children_yaml)?; if children.is_empty() { return Err("## Children порожня — плановик не дав жодної дитини".to_string()); @@ -170,12 +301,14 @@ fn spawn_approve( tasks_dir: String, task_path: String, ) -> Result { + assert_owned(&tasks_dir, &task_path)?; mt_core::spawn::spawn_approve(&tasks_dir, &task_path) } /// Вердикт власника: reject плану з причиною → plan-rejected_NNN.md. #[tauri::command] fn spawn_reject(tasks_dir: String, task_path: String, reason: String) -> Result { + assert_owned(&tasks_dir, &task_path)?; mt_core::spawn::spawn_reject(&tasks_dir, &task_path, &reason) } @@ -187,6 +320,7 @@ fn human_done( task_path: String, summary: String, ) -> Result { + assert_owned(&tasks_dir, &task_path)?; if let Err(e) = mt_core::signal::write_fact(&tasks_dir, &task_path, &summary, None) { // Порожній/битий Summary — справжня помилка; наявний fact — ні // (retry після Check-фейлу не перетирає його). @@ -241,6 +375,9 @@ pub fn run() { read_task, read_autonomy, write_autonomy, + scan_owners, + get_identity, + set_identity, plan_review_info, spawn_approve, spawn_reject, @@ -375,4 +512,49 @@ mod tests { ) .is_err()); } + + #[test] + fn autonomy_accepts_reserved_owner_and_since() { + assert!(validate_autonomy("owner: olena\nsince: 2026-07-13\ndeploy: approve\n").is_ok()); + // owner з пробілами / порожній — відмова (handle — один токен) + assert!(validate_autonomy("owner: Олена Коваль\n").is_err()); + assert!(validate_autonomy("owner:\n").is_err()); + } + + #[test] + fn scan_owners_collects_marked_nodes_recursively() { + let (tmp, node) = goal_node(); + let child = Path::new(&node).join("collect"); + fs::create_dir_all(&child).unwrap(); + fs::write(child.join("task.md"), "---\nschema_version: 1\n---\n").unwrap(); + fs::write(child.join("autonomy.yml"), "owner: olena\n").unwrap(); + // autonomy.yml без owner — не розмітка + fs::write(Path::new(&node).join("autonomy.yml"), "deploy: approve\n").unwrap(); + + let owners = scan_owners(tmp.path().to_string_lossy().into_owned()).unwrap(); + assert_eq!(owners.len(), 1); + assert_eq!( + owners.get("goal/collect").map(String::as_str), + Some("olena") + ); + } + + #[test] + fn check_scope_unmarked_forest_allows_everyone() { + let owners = std::collections::HashMap::new(); + assert!(check_scope(&owners, "goal", None).is_ok()); + } + + #[test] + fn check_scope_enforces_effective_owner_with_inheritance() { + let owners = std::collections::HashMap::from([("goal".to_string(), "olena".to_string())]); + // дитина успадковує власника предка + assert!(check_scope(&owners, "goal/collect", Some("olena")).is_ok()); + let err = check_scope(&owners, "goal/collect", Some("vkozlov")).unwrap_err(); + assert!(err.contains("olena")); + // розмічений ліс без ідентичності — fail-closed + assert!(check_scope(&owners, "goal", None).is_err()); + // «нічия земля»: вузол поза розміченими піддеревами — діяти можна + assert!(check_scope(&owners, "orphan", Some("vkozlov")).is_ok()); + } } diff --git a/owner/src/autonomy.js b/owner/src/autonomy.js index 3f51819..b7b3bba 100644 --- a/owner/src/autonomy.js +++ b/owner/src/autonomy.js @@ -10,9 +10,15 @@ export const GATES = Object.freeze(['auto', 'approve']) // Відкритий словник класів дій зі спеки; 'default' — фолбек для нездекларованих. export const ACTION_CLASSES = Object.freeze(['deploy', 'external_comms', 'spend', 'worktree_edit']) +// Зарезервовані метаключі поза словником класів дій (M5, спека +// 260714-cognitive-delegation): owner — handle власника піддерева, +// since — дата делегування. Дзеркалить RESERVED_KEYS Rust-бекенда. +export const RESERVED_KEYS = Object.freeze(['owner', 'since']) + /** * Розбирає текст `autonomy.yml` (плоскі рядки `клас: gate`) у політику вузла. - * Порожні рядки й `#`-коментарі ігноруються. + * Порожні рядки, `#`-коментарі та зарезервовані метаключі (owner/since) + * ігноруються — власність читає effectiveOwnerOf у scope.js. * @param {string} text сирий вміст файлу (порожній рядок — вузол без своєї політики) * @returns {Record} декларована на вузлі політика */ @@ -24,6 +30,7 @@ export function parseAutonomy(text) { const sep = line.indexOf(':') if (sep === -1) throw new Error(`autonomy.yml: невалідний рядок ${JSON.stringify(line)}`) const key = line.slice(0, sep).trim() + if (RESERVED_KEYS.includes(key)) continue const value = line.slice(sep + 1).trim() if (!GATES.includes(value)) throw new Error(`autonomy.yml: клас ${key} — невідомий гейт ${JSON.stringify(value)}`) policy[key] = value diff --git a/owner/src/components/DecisionCard.vue b/owner/src/components/DecisionCard.vue index 292d9d7..951854e 100644 --- a/owner/src/components/DecisionCard.vue +++ b/owner/src/components/DecisionCard.vue @@ -2,6 +2,12 @@ + {{ decision.node.path }} {{ decision.workspace.label }} diff --git a/owner/src/components/OnboardingDialog.vue b/owner/src/components/OnboardingDialog.vue index c4f20ac..d1eccaf 100644 --- a/owner/src/components/OnboardingDialog.vue +++ b/owner/src/components/OnboardingDialog.vue @@ -56,6 +56,27 @@ + +
Хто ти
+

+ Handle визначає твій скоуп: черга і задачі фільтруються по піддеревах, де owner: — це ти. Той + самий формат, що assignee в h.md; email та імʼя лишаються поза git. Ліс без + owner:-розмітки — увесь твій. +

+ +
+
@@ -106,7 +127,26 @@ const { baseUrl, model, apiKey, saveOmlx } = usePlanner() const paths = ref([]) const newPath = ref('') const saving = ref(false) +const handle = ref(null) +const knownHandles = ref([]) let dirty = false +let savedHandle = null + +/** + * Handles, що вже зустрічаються в owner:-розмітці лісу — підказки для + * вибору «хто ти» (замість введення наосліп). + * @returns {Promise} + */ +async function loadKnownHandles() { + const found = await dispatch('workspaces') + if (!found.ok) return + const handles = new Set() + for (const workspace of found.output) { + const marked = await dispatch('scan_owners', { tasksDir: workspace.path }) + if (marked.ok) for (const owner of Object.values(marked.output)) handles.add(owner) + } + knownHandles.value = [...handles].toSorted() +} watch( () => props.modelValue, @@ -115,6 +155,10 @@ watch( dirty = false const read = await dispatch('project_paths') if (read.ok) paths.value = read.output + const me = await dispatch('whoami') + savedHandle = me.ok ? (me.output ?? null) : null + handle.value = savedHandle + await loadKnownHandles() } ) @@ -166,6 +210,13 @@ async function start() { return } } + if ((handle.value ?? '') !== (savedHandle ?? '')) { + const saved = await dispatch('set_identity', { handle: handle.value ?? '' }) + if (!saved.ok) { + $q.notify({ type: 'negative', message: saved.error.message }) + return + } + } markOnboarded() emit('update:modelValue', false) emit('started') @@ -274,6 +325,15 @@ async function start() { flex: 1; } +.ob-identity { + padding-top: 8px; + padding-bottom: 0; +} + +.ob-identity-input { + max-width: 260px; +} + .ob-model { padding-top: 8px; padding-bottom: 0; diff --git a/owner/src/components/OwnerScreen.vue b/owner/src/components/OwnerScreen.vue index 523fc7c..8b42cb0 100644 --- a/owner/src/components/OwnerScreen.vue +++ b/owner/src/components/OwnerScreen.vue @@ -17,6 +17,15 @@ :loading="criticRunning" /> +
@@ -59,7 +68,7 @@ import PlannerDialog from './PlannerDialog.vue' // заголовок оголошує причину, ручний перемикач — вихід із адаптивності. // Детермінований критик оновлюється з кожним rescan; семантичний — кнопкою. -const { workspaces, forest, delta, loading, rescan, watchForest } = useForest() +const { workspaces, forest, delta, loading, scopes, identity, rescan, watchForest } = useForest() const { verdicts: criticVerdicts, running: criticRunning, refreshDeterministic, runSemantic, dismiss } = useCritic() watch(forest, value => refreshDeterministic(workspaces.value, value)) @@ -74,8 +83,8 @@ const manualMode = ref(null) const plannerOpen = ref(false) const onboardingOpen = ref(false) -const decisions = computed(() => collectDecisions(workspaces.value, forest.value)) -const personal = computed(() => collectPersonal(workspaces.value, forest.value)) +const decisions = computed(() => collectDecisions(workspaces.value, forest.value, scopes.value)) +const personal = computed(() => collectPersonal(workspaces.value, forest.value, scopes.value)) const auto = computed(() => chooseMode({ decisionCount: decisions.value.length + criticVerdicts.value.length, deltaCount: delta.value.length }) diff --git a/owner/src/components/docs/DecisionCard.md b/owner/src/components/docs/DecisionCard.md index df1dc65..c12d06c 100644 --- a/owner/src/components/docs/DecisionCard.md +++ b/owner/src/components/docs/DecisionCard.md @@ -3,7 +3,7 @@ type: Vue Component title: DecisionCard.vue resource: owner/src/components/DecisionCard.vue docgen: - crc: 7af66263 + crc: b3ab9523 model: openai-codex/gpt-5.4-mini --- diff --git a/owner/src/components/docs/OnboardingDialog.md b/owner/src/components/docs/OnboardingDialog.md index e608d3c..6a0d836 100644 --- a/owner/src/components/docs/OnboardingDialog.md +++ b/owner/src/components/docs/OnboardingDialog.md @@ -3,7 +3,7 @@ type: Vue Component title: OnboardingDialog.vue resource: owner/src/components/OnboardingDialog.vue docgen: - crc: b0073ad4 + crc: be154859 model: omlx/gemma-4-e4b-it-OptiQ-4bit --- diff --git a/owner/src/components/docs/OwnerScreen.md b/owner/src/components/docs/OwnerScreen.md index edb5e42..6d46f6e 100644 --- a/owner/src/components/docs/OwnerScreen.md +++ b/owner/src/components/docs/OwnerScreen.md @@ -3,7 +3,7 @@ type: Vue Component title: OwnerScreen.vue resource: owner/src/components/OwnerScreen.vue docgen: - crc: e424293a + crc: 2c48ef8d model: openai-codex/gpt-5.4-mini --- diff --git a/owner/src/composables/docs/use-forest.md b/owner/src/composables/docs/use-forest.md index 1e32c5c..e71d43f 100644 --- a/owner/src/composables/docs/use-forest.md +++ b/owner/src/composables/docs/use-forest.md @@ -3,9 +3,8 @@ type: JS Module title: use-forest.js resource: owner/src/composables/use-forest.js docgen: - crc: 167db866 + crc: f4c6a603 model: openai-codex/gpt-5.4-mini - tier: cloud-min score: 100 issues: judge:inaccurate:0.98 judgeModel: openai-codex/gpt-5.4-mini diff --git a/owner/src/composables/use-forest.js b/owner/src/composables/use-forest.js index 28112e6..02dd8a6 100644 --- a/owner/src/composables/use-forest.js +++ b/owner/src/composables/use-forest.js @@ -1,22 +1,41 @@ import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { diffForest, readSnapshot, snapshotForest, writeSnapshot } from '../delta.js' +import { deriveScopes } from '../scope.js' import { dispatch } from '../tool/index.js' // Спільний стан лісу задач власника: воркспейси + дерева вузлів + дельта // проти знімка минулого візиту. Baseline фіксується на першому завантаженні // сесії — refresh порівнює з ним, а не з щойно записаним знімком, тож дельта // не «зʼїдає» сама себе посеред сесії. +// M5: ліс доповнюється owner:-розміткою (scan_owners) та ідентичністю +// (whoami) — з них деривуються скоупи, що фільтрують чергу/задачі/дельту. const workspaces = ref([]) const forest = ref({}) const loading = ref(false) const delta = ref([]) +const identity = ref(null) +const scopes = ref({}) let baseline = null let watching = false let debounceTimer = null +/** + * Лишає у дельті лише мої новини: мій скоуп, межі контрактів (стан + * делегованого вузла — новина замовника) і «нічию землю». + * @param {Array<{ workspace: string, path: string }>} rows рядки дельти + * @param {Record string }>} scopeByWs скоуп за шляхом воркспейсу + * @returns {Array} відфільтровані рядки + */ +function scopeDelta(rows, scopeByWs) { + return rows.filter(row => { + const cls = scopeByWs[row.workspace]?.classify(row.path) ?? 'mine' + return cls !== 'foreign' + }) +} + /** * Сканує всі воркспейси лісу через tool-поверхню і оновлює дельту. * @returns {Promise} @@ -28,16 +47,23 @@ async function rescan() { if (!found.ok) throw new Error(found.error.message) workspaces.value = found.output.map(w => ({ label: w.label, path: w.path })) + const me = await dispatch('whoami') + identity.value = me.ok ? (me.output ?? null) : null + const trees = {} + const ownersByWs = {} for (const workspace of workspaces.value) { const scanned = await dispatch('scan', { tasksDir: workspace.path }) trees[workspace.path] = scanned.ok ? scanned.output : [] + const marked = await dispatch('scan_owners', { tasksDir: workspace.path }) + ownersByWs[workspace.path] = marked.ok ? marked.output : {} } forest.value = trees + scopes.value = deriveScopes(ownersByWs, identity.value) const current = snapshotForest(trees) baseline ??= readSnapshot() ?? current - delta.value = diffForest(baseline, current) + delta.value = scopeDelta(diffForest(baseline, current), scopes.value) writeSnapshot(current) } catch (error) { console.error('forest rescan failed', error) diff --git a/owner/src/decisions.js b/owner/src/decisions.js index 86d9627..8283469 100644 --- a/owner/src/decisions.js +++ b/owner/src/decisions.js @@ -43,30 +43,44 @@ function walk(nodes, pick) { } /** - * Рішення, що чекають власника, з усього лісу — відсортовані за ціною помилки. + * Рішення, що чекають власника, з мого скоупу лісу — відсортовані за ціною + * помилки. Без scopes (нерозмічений ліс / legacy-виклик) — увесь ліс, як + * раніше. «Нічия земля» (orphaned) потрапляє в чергу всім із прапорцем — + * загублена ескалація гірша за зайвий рядок (M5, спека 260714). * @param {Array<{ label: string, path: string }>} workspaces воркспейси лісу * @param {Record} forest дерева вузлів за шляхом воркспейсу - * @returns {Array<{ node: object, workspace: object, stake: number, headline: string, actions: string[] }>} черга рішень + * @param {Record string }>} [scopes] скоуп за шляхом воркспейсу + * @returns {Array<{ node: object, workspace: object, stake: number, headline: string, actions: string[], orphaned?: boolean }>} черга рішень */ -export function collectDecisions(workspaces, forest) { +export function collectDecisions(workspaces, forest, scopes) { const rows = workspaces.flatMap(workspace => walk(forest[workspace.path], node => { const verdict = VERDICTS[node.state] - return verdict ? { node, workspace, ...verdict } : null + if (!verdict) return null + const cls = scopes?.[workspace.path]?.classify(node.path) ?? 'mine' + if (cls === 'mine') return { node, workspace, ...verdict } + if (cls === 'orphaned') return { node, workspace, ...verdict, orphaned: true } + return null }) ) return rows.toSorted((a, b) => a.stake - b.stake) } /** - * Особисті задачі власника — pending h.md-вузли: це робота, не рішення, - * тож у чергу вони не потрапляють (показуються у брифі). + * Особисті задачі власника — pending h.md-вузли мого скоупу: це робота, + * не рішення, тож у чергу вони не потрапляють (показуються у брифі). + * Чужі й нічийні pending — не мої задачі. * @param {Array<{ label: string, path: string }>} workspaces воркспейси лісу * @param {Record} forest дерева вузлів за шляхом воркспейсу + * @param {Record string }>} [scopes] скоуп за шляхом воркспейсу * @returns {Array<{ node: object, workspace: object }>} задачі на сьогодні */ -export function collectPersonal(workspaces, forest) { +export function collectPersonal(workspaces, forest, scopes) { return workspaces.flatMap(workspace => - walk(forest[workspace.path], node => (node.state === 'pending' ? { node, workspace } : null)) + walk(forest[workspace.path], node => + node.state === 'pending' && (scopes?.[workspace.path]?.classify(node.path) ?? 'mine') === 'mine' + ? { node, workspace } + : null + ) ) } diff --git a/owner/src/docs/autonomy.md b/owner/src/docs/autonomy.md index a2aab88..32967f9 100644 --- a/owner/src/docs/autonomy.md +++ b/owner/src/docs/autonomy.md @@ -3,9 +3,8 @@ type: JS Module title: autonomy.js resource: owner/src/autonomy.js docgen: - crc: af749c01 + crc: f6ef7458 model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min-retry score: 100 issues: judge:inaccurate:0.98 judgeModel: openai-codex/gpt-5.4-mini diff --git a/owner/src/docs/decisions.md b/owner/src/docs/decisions.md index c0d19bf..958af97 100644 --- a/owner/src/docs/decisions.md +++ b/owner/src/docs/decisions.md @@ -3,9 +3,8 @@ type: JS Module title: decisions.js resource: owner/src/decisions.js docgen: - crc: f8de9196 + crc: fdf082b8 model: openai-codex/gpt-5.4-mini - tier: cloud-min score: 100 issues: judge:inaccurate:0.93 judgeModel: openai-codex/gpt-5.4-mini diff --git a/owner/src/docs/index.md b/owner/src/docs/index.md index e061bd2..b8b39df 100644 --- a/owner/src/docs/index.md +++ b/owner/src/docs/index.md @@ -15,5 +15,6 @@ resource: owner/src/ | [mission.js](mission.md) | JS Module | | [onboarding.js](onboarding.md) | JS Module | | [planner.js](planner.md) | JS Module | +| [scope.js](scope.md) | JS Module | | [screen-mode.js](screen-mode.md) | JS Module | | [staff.js](staff.md) | JS Module | diff --git a/owner/src/docs/scope.md b/owner/src/docs/scope.md new file mode 100644 index 0000000..2d59d5a --- /dev/null +++ b/owner/src/docs/scope.md @@ -0,0 +1,37 @@ +--- +type: JS Module +title: scope.js +resource: owner/src/scope.js +docgen: + crc: 655e3ef9 + model: omlx/gemma-4-e4b-it-OptiQ-4bit + tier: local-min + score: 100 + issues: judge:inaccurate:0.98 + judgeModel: openai-codex/gpt-5.4-mini +--- + +## Огляд + +Визначає ефективного власника шляху, розраховує області видимості та класифікації вузлів воркспейсу, використовуючи публічні функції `effectiveOwnerOf`, `deriveScope` та `deriveScopes`. + +## Поведінка + +Поведінка +effectiveOwnerOf визначає найдовший префікс шляху з позначкою власника у наданому розміщенні. +deriveScope визначає класифікацію вузлів воркспейсу відносно заданої особи, якщо розмітка воркспейсу і моя ідентичність визначені. +deriveScopes обчислює класифікацію вузлів воркспейсу для кожного шляху у наданому розміщенні воркспейсу, використовуючи мою ідентичність. + +## Публічний API + +effectiveOwnerOf — Визначає основного відповідального за вузол, беручи найдовший префікс шляху, позначений як власника, і синхронізує це з Rust-бекендом. +deriveScope — Класифікує вузли у робочому просторі відповідно до мого статусу. +mine — Позначає, що я є ефективним власником або що дерево не містить розмітки власника. +boundary — Позначає межу контракту: вузол належить іншому, але його батько належить мені; я бачу лише фасад цього контракту. +foreign — Позначає елементи поза межами мого контракту, які не обробляються в черзі чи дельті. +orphaned — Позначає дерево, що має розмітку власника, але власник не може бути визначений, відображаючи всім запрошення про призначення власника. +deriveScopes — Визначає скоупи кожного робочого простору дерева, виходячи з розмітки та моєї особистості. + +## Гарантії поведінки + +- Read-only: не виконує операцій запису (ФС/БД). diff --git a/owner/src/scope.js b/owner/src/scope.js new file mode 100644 index 0000000..318b169 --- /dev/null +++ b/owner/src/scope.js @@ -0,0 +1,67 @@ +// Скоуп власника (M5, docs/specs/260714-cognitive-delegation.md): «мій скоуп» +// — не налаштування, а деривація з owner:-розмітки autonomy.yml у git. +// Фрактальне успадкування йде за графом задач, не за оргструктурою: власник +// вузла володіє нащадками, доки нащадок не делегований комусь іншому. +// Ліс без жодного owner: — нерозмічений: усе моє (single-owner виродження). + +/** + * Ефективний власник вузла: найдовший префікс шляху з owner:-розміткою + * (дзеркалить effective_owner_of у Rust-бекенді). + * @param {Record} owners розмітка воркспейсу {nodePath: handle} + * @param {string} path шлях вузла (сегменти через «/») + * @returns {string|null} handle власника, або null — вузол без власника + */ +export function effectiveOwnerOf(owners, path) { + const segments = path.split('/') + for (let i = segments.length; i >= 1; i--) { + const owner = owners?.[segments.slice(0, i).join('/')] + if (owner) return owner + } + return null +} + +/** + * Класифікація вузлів воркспейсу відносно мене. + * - `mine` — я effective owner (або ліс нерозмічений); + * - `boundary` — межа контракту: вузол делеговано іншому, а його батько мій + * (я замовник — бачу контрактний фасад і зміни стану самого вузла); + * - `foreign` — чужа кухня під межею (не вантажиться у чергу/дельту); + * - `orphaned` — «нічия земля»: розмічений ліс, а власник не резолвиться + * (fail-visible: показується всім із запрошенням призначити власника). + * @param {Record} owners розмітка воркспейсу {nodePath: handle} + * @param {string|null} me мій handle (null — ідентичність не налаштована) + * @returns {{ marked: boolean, classify: (path: string) => 'mine'|'boundary'|'foreign'|'orphaned' }} скоуп воркспейсу + */ +export function deriveScope(owners, me) { + const marked = Object.keys(owners ?? {}).length > 0 + + /** + * @param {string} path шлях вузла + * @returns {'mine'|'boundary'|'foreign'|'orphaned'} клас вузла відносно мене + */ + function classify(path) { + if (!marked) return 'mine' + const owner = effectiveOwnerOf(owners, path) + if (owner === null) return 'orphaned' + if (me !== null && owner === me) return 'mine' + // Чужий вузол: межа контракту — сам розмічений вузол, чий батько мій. + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null + const parentOwner = parent === null ? null : effectiveOwnerOf(owners, parent) + const parentIsMine = parent !== null && me !== null && parentOwner === me + return owners[path] && parentIsMine ? 'boundary' : 'foreign' + } + + return { marked, classify } +} + +/** + * Скоуп кожного воркспейсу лісу з розмітки й моєї ідентичності. + * @param {Record>} ownersByWorkspace розмітка за шляхом воркспейсу + * @param {string|null} me мій handle + * @returns {Record>} скоуп за шляхом воркспейсу + */ +export function deriveScopes(ownersByWorkspace, me) { + return Object.fromEntries( + Object.entries(ownersByWorkspace ?? {}).map(([path, owners]) => [path, deriveScope(owners, me)]) + ) +} diff --git a/owner/src/tests/decisions.test.js b/owner/src/tests/decisions.test.js index 25839bf..f21ea02 100644 --- a/owner/src/tests/decisions.test.js +++ b/owner/src/tests/decisions.test.js @@ -59,3 +59,41 @@ describe('collectPersonal', () => { expect(decisions).toEqual([]) }) }) + +describe('скоуп власника (M5)', () => { + const forest = { + '/demo/mt': [ + node('mine', 'plan_review', [node('mine/todo', 'pending'), node('mine/delegated', 'unresolvable')]), + node('stray', 'failed'), + node('mine2', 'pending') + ] + } + // mine* — мої, mine/delegated — делеговано, stray — «нічия земля» + const scopes = { + '/demo/mt': { + marked: true, + classify: path => { + if (path === 'mine/delegated') return 'boundary' + if (path === 'stray') return 'orphaned' + return path.startsWith('mine') ? 'mine' : 'foreign' + } + } + } + + it('черга — лише мій скоуп, «нічия земля» з прапорцем, межі без рішень', () => { + const rows = collectDecisions([WS], forest, scopes) + expect(rows.map(r => [r.node.path, r.orphaned ?? false])).toEqual([ + ['stray', true], + ['mine', false] + ]) + }) + + it('особисті задачі — лише мої pending', () => { + const forest2 = { '/demo/mt': [node('mine/todo', 'pending'), node('stray', 'pending')] } + expect(collectPersonal([WS], forest2, scopes).map(r => r.node.path)).toEqual(['mine/todo']) + }) + + it('без scopes — легасі-поведінка: усе моє', () => { + expect(collectDecisions([WS], forest).length).toBeGreaterThan(2) + }) +}) diff --git a/owner/src/tests/scope.test.js b/owner/src/tests/scope.test.js new file mode 100644 index 0000000..77877fd --- /dev/null +++ b/owner/src/tests/scope.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { deriveScope, deriveScopes, effectiveOwnerOf } from '../scope.js' + +describe('effectiveOwnerOf', () => { + it('повертає власника вузла або найближчого розміченого предка', () => { + const owners = { goal: 'olena', 'goal/api/deploy': 'vkozlov' } + expect(effectiveOwnerOf(owners, 'goal')).toBe('olena') + expect(effectiveOwnerOf(owners, 'goal/api')).toBe('olena') + expect(effectiveOwnerOf(owners, 'goal/api/deploy')).toBe('vkozlov') + expect(effectiveOwnerOf(owners, 'goal/api/deploy/tests')).toBe('vkozlov') + }) + + it('вузол поза розміченими піддеревами — без власника', () => { + expect(effectiveOwnerOf({ goal: 'olena' }, 'other')).toBeNull() + expect(effectiveOwnerOf({}, 'goal')).toBeNull() + }) +}) + +describe('deriveScope', () => { + it('нерозмічений ліс — усе моє (single-owner виродження)', () => { + const scope = deriveScope({}, null) + expect(scope.marked).toBe(false) + expect(scope.classify('anything/at/all')).toBe('mine') + }) + + it('розмічений ліс: mine / boundary / foreign / orphaned', () => { + const owners = { goal: 'olena', 'goal/api': 'vkozlov' } + const scope = deriveScope(owners, 'olena') + expect(scope.marked).toBe(true) + expect(scope.classify('goal')).toBe('mine') + expect(scope.classify('goal/docs')).toBe('mine') + // делегований вузол, чий батько мій — межа контракту + expect(scope.classify('goal/api')).toBe('boundary') + // кухня під межею — чужа + expect(scope.classify('goal/api/impl')).toBe('foreign') + // розмічений ліс, власник не резолвиться — «нічия земля» + expect(scope.classify('orphan')).toBe('orphaned') + }) + + it('той самий ліс очима іншого власника', () => { + const owners = { goal: 'olena', 'goal/api': 'vkozlov' } + const scope = deriveScope(owners, 'vkozlov') + expect(scope.classify('goal/api')).toBe('mine') + expect(scope.classify('goal/api/impl')).toBe('mine') + expect(scope.classify('goal')).toBe('foreign') + expect(scope.classify('goal/docs')).toBe('foreign') + }) + + it('розмічений ліс без ідентичності — нічого мого, orphaned видно', () => { + const scope = deriveScope({ goal: 'olena' }, null) + expect(scope.classify('goal')).toBe('foreign') + expect(scope.classify('stray')).toBe('orphaned') + }) + + it('чужий корінь без мого батька — foreign, не boundary', () => { + const scope = deriveScope({ theirs: 'vkozlov' }, 'olena') + expect(scope.classify('theirs')).toBe('foreign') + }) +}) + +describe('deriveScopes', () => { + it('скоуп на кожен воркспейс лісу', () => { + const scopes = deriveScopes({ '/a/mt': { goal: 'olena' }, '/b/mt': {} }, 'olena') + expect(scopes['/a/mt'].marked).toBe(true) + expect(scopes['/b/mt'].marked).toBe(false) + expect(scopes['/b/mt'].classify('x')).toBe('mine') + }) +}) diff --git a/owner/src/tests/tool.test.js b/owner/src/tests/tool.test.js index 8be760b..c81ba1b 100644 --- a/owner/src/tests/tool.test.js +++ b/owner/src/tests/tool.test.js @@ -20,6 +20,7 @@ describe('owner tool catalog', () => { 'draft_plan', 'mark_done', 'reject_plan', + 'set_identity', 'set_project_paths', 'write_autonomy' ]) diff --git a/owner/src/tool/catalog.js b/owner/src/tool/catalog.js index ee5bc7f..f215ef3 100644 --- a/owner/src/tool/catalog.js +++ b/owner/src/tool/catalog.js @@ -107,6 +107,29 @@ export const TOOLS = [ }, tauri: 'spawn_reject' }, + { + tier: 'read', + name: 'whoami', + summary: "Read the configured owner identity handle (null — the 'who are you' step not done yet).", + input: {}, + tauri: 'get_identity' + }, + { + tier: 'write', + name: 'set_identity', + summary: 'Persist the owner identity handle locally (PII stays out of git — mt directory policy).', + input: { + handle: { type: 'string', required: true, description: 'Owner handle, same format as h.md assignee.' } + }, + tauri: 'set_identity' + }, + { + tier: 'read', + name: 'scan_owners', + summary: 'Collect the owner: markup of a workspace (autonomy.yml files) — raw input for scope derivation.', + input: { tasksDir: TASKS_DIR }, + tauri: 'scan_owners' + }, { tier: 'read', name: 'read_autonomy', diff --git a/owner/src/tool/docs/catalog.md b/owner/src/tool/docs/catalog.md index 77fa0b2..00e099d 100644 --- a/owner/src/tool/docs/catalog.md +++ b/owner/src/tool/docs/catalog.md @@ -3,7 +3,7 @@ type: JS Module title: catalog.js resource: owner/src/tool/catalog.js docgen: - crc: 79287e9a + crc: f68b3f3d model: openai-codex/gpt-5.5 score: 100 issues: judge:inaccurate:0.95