Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"staticlib",
"stry",
"tctl",
"vkozlov",
"tmpdirs",
"toplevel",
"Автовибір",
Expand Down Expand Up @@ -94,6 +95,8 @@
"логується",
"макроса",
"машинно",
"метаключ",
"метаключі",
"монтирує",
"напр",
"автовизначити",
Expand Down Expand Up @@ -153,6 +156,10 @@
"рендериться",
"рендером",
"самозатиралися",
"скоуп",
"скоупи",
"скоупу",
"скоупом",
"сконфігуровазованого",
"спавнені",
"таймаут",
Expand Down
5 changes: 5 additions & 0 deletions app/.changes/260715-1655.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: patch
section: Changed
---
Освіжено файлову доку `NodeActions.md` (CRC-дрейф, без змін поведінки).
5 changes: 5 additions & 0 deletions owner/.changes/260715-1600.md
Original file line number Diff line number Diff line change
@@ -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 поза скоупом, крок «Хто ти» в онбордингу
48 changes: 40 additions & 8 deletions owner/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -47,17 +50,46 @@ pub fn get_project_paths() -> Vec<String> {
.unwrap_or_default()
}

/// Зберігає project paths у власний конфіг owner.
pub fn set_project_paths(paths: Vec<String>) -> 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<String>) -> Result<(), String> {
merge_own_config("project_paths", json!(paths))
}

/// Handle власника з конфігу (None — ідентичність ще не налаштована).
pub fn get_identity() -> Option<String> {
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::*;
Expand Down
3 changes: 1 addition & 2 deletions owner/src-tauri/src/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion owner/src-tauri/src/docs/lib.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
198 changes: 190 additions & 8 deletions owner/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,30 +45,159 @@ fn create_task(tasks_dir: String, name: String, opts: CreateOpts) -> Result<Crea
mt_core::create_task(tasks_dir, name, opts)
}

/// Зарезервовані метаключі `autonomy.yml` поза словником класів дій
/// (M5, спека 260714-cognitive-delegation): `owner` — handle власника
/// піддерева (як `assignee` у h.md, PII поза git), `since` — дата
/// делегування. Живуть у тому самому поліс-файлі, бо власник і конверт
/// автономії народжуються одним актом делегування.
const RESERVED_KEYS: [&str; 2] = ["owner", "since"];

/// Валідує `autonomy.yml` (M3): непорожні рядки без `#` — мають бути
/// `клас: auto|approve`. Дзеркалить парсер owner/src/autonomy.js — файл
/// не входить у контракт mt-core (a.md перезаписується цілком при зміні
/// виконавця), тож owner сам відповідає за цілісність свого шару.
/// `клас: auto|approve` або зарезервований метаключ із непорожнім
/// значенням. Дзеркалить парсер owner/src/autonomy.js — файл не входить
/// у контракт mt-core (a.md перезаписується цілком при зміні виконавця),
/// тож owner сам відповідає за цілісність свого шару.
fn validate_autonomy(yaml: &str) -> 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<String> {
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<String, String>) {
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<std::collections::HashMap<String, String>, 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<String> {
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<String, String>,
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<String, String>,
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<String, String> {
Expand All @@ -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())
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -170,12 +301,14 @@ fn spawn_approve(
tasks_dir: String,
task_path: String,
) -> Result<mt_core::spawn::SpawnOutcome, String> {
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<String, String> {
assert_owned(&tasks_dir, &task_path)?;
mt_core::spawn::spawn_reject(&tasks_dir, &task_path, &reason)
}

Expand All @@ -187,6 +320,7 @@ fn human_done(
task_path: String,
summary: String,
) -> Result<mt_core::signal::SignalOutcome, String> {
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-фейлу не перетирає його).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
}
Loading
Loading