Skip to content

Commit 2715fd7

Browse files
vitaliytvclaude
andauthored
feat(owner): M5 — скоуп власника (фрактальна власність) (#6)
* ✨ feat(owner): M5 — скоуп власника (фрактальна власність, спека 260714) Ідентичність-handle і scope-деривація з owner:-розмітки autonomy.yml: - Rust: get_identity/set_identity (локальний конфіг, PII поза git; merge-запис конфігу не затирає інші ключі), scan_owners (один прохід ФС), зарезервовані метаключі owner/since у validate_autonomy, fail-closed гейт власності (check_scope) на write-командах spawn_approve/spawn_reject/human_done/draft_plan/write_autonomy; нерозмічений ліс — поведінка як раніше, «нічия земля» не блокується - JS: scope.js (effectiveOwnerOf/deriveScope: mine/boundary/foreign/ orphaned), фільтр черги рішень і особистих задач (decisions.js), дельти (use-forest.js: foreign-рядки відсікаються, межі контрактів лишаються); parseAutonomy ігнорує зарезервовані ключі - Tools: whoami/set_identity/scan_owners у каталозі (headless-паритет) - UI: крок «Хто ти» в онбордингу (вибір із handles лісу + вільне введення), ідентичність у тулбарі, бейдж «нічия земля» на картці - Тести: scope.test.js + скоуп-кейси decisions, Rust check_scope/ scan_owners; 135 JS + 10 Rust зелені Критерій M5: два власники над одним репо бачать різні черги; ліс без owner:-розмітки працює як сьогодні. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 📝 docs(owner): регенерація lib.md після мерджу main (CRC) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 61196f3 commit 2715fd7

27 files changed

Lines changed: 626 additions & 42 deletions

.cspell.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"staticlib",
5252
"stry",
5353
"tctl",
54+
"vkozlov",
5455
"tmpdirs",
5556
"toplevel",
5657
"Автовибір",
@@ -94,6 +95,8 @@
9495
"логується",
9596
"макроса",
9697
"машинно",
98+
"метаключ",
99+
"метаключі",
97100
"монтирує",
98101
"напр",
99102
"автовизначити",
@@ -153,6 +156,10 @@
153156
"рендериться",
154157
"рендером",
155158
"самозатиралися",
159+
"скоуп",
160+
"скоупи",
161+
"скоупу",
162+
"скоупом",
156163
"сконфігуровазованого",
157164
"спавнені",
158165
"таймаут",

app/.changes/260715-1655.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
bump: patch
3+
section: Changed
4+
---
5+
Освіжено файлову доку `NodeActions.md` (CRC-дрейф, без змін поведінки).

owner/.changes/260715-1600.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
bump: minor
3+
section: Added
4+
---
5+
M5 — скоуп власника (спека docs/specs/260714-cognitive-delegation.md): ідентичність-handle (whoami/set_identity), owner: в autonomy.yml, scan_owners, scope-фільтр черги/особистих задач/дельти, «нічия земля», fail-closed write-tools поза скоупом, крок «Хто ти» в онбордингу

owner/src-tauri/src/config.rs

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
//! Конфіг owner-застосунку — project search paths.
1+
//! Конфіг owner-застосунку — project search paths та ідентичність власника.
22
//!
3-
//! Зберігається у `appLocalDataDir/config.json` (`{ "project_paths": [...] }`).
4-
//! Fallback-ланцюжок читання: власний конфіг → конфіг app (`com.nitra.task`,
5-
//! щоб обидва застосунки бачили той самий ліс без налаштування) → `~/www`.
3+
//! Зберігається у `appLocalDataDir/config.json`
4+
//! (`{ "project_paths": [...], "identity": "handle" }`).
5+
//! Fallback-ланцюжок читання шляхів: власний конфіг → конфіг app
6+
//! (`com.nitra.task`, щоб обидва застосунки бачили той самий ліс без
7+
//! налаштування) → `~/www`. Ідентичність — handle (як `assignee` у `h.md`,
8+
//! PII лишається поза git) — тільки з власного конфігу, без fallback.
69
710
use std::fs;
811
use std::path::{Path, PathBuf};
@@ -47,17 +50,46 @@ pub fn get_project_paths() -> Vec<String> {
4750
.unwrap_or_default()
4851
}
4952

50-
/// Зберігає project paths у власний конфіг owner.
51-
pub fn set_project_paths(paths: Vec<String>) -> Result<(), String> {
53+
/// Читає весь власний конфіг (відсутній/битий файл — порожній обʼєкт).
54+
fn read_own_config() -> Value {
55+
own_config_path()
56+
.ok()
57+
.and_then(|p| fs::read_to_string(p).ok())
58+
.and_then(|raw| serde_json::from_str(&raw).ok())
59+
.unwrap_or_else(|| json!({}))
60+
}
61+
62+
/// Мерджить один ключ у власний конфіг, зберігаючи решту ключів.
63+
fn merge_own_config(key: &str, value: Value) -> Result<(), String> {
5264
let path = own_config_path()?;
5365
if let Some(parent) = path.parent() {
5466
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
5567
}
56-
let body = serde_json::to_string_pretty(&json!({ "project_paths": paths }))
57-
.map_err(|e| e.to_string())?;
68+
let mut config = read_own_config();
69+
config
70+
.as_object_mut()
71+
.ok_or("config.json: корінь не обʼєкт")?
72+
.insert(key.to_string(), value);
73+
let body = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
5874
fs::write(path, body).map_err(|e| e.to_string())
5975
}
6076

77+
/// Зберігає project paths у власний конфіг owner (інші ключі не чіпає).
78+
pub fn set_project_paths(paths: Vec<String>) -> Result<(), String> {
79+
merge_own_config("project_paths", json!(paths))
80+
}
81+
82+
/// Handle власника з конфігу (None — ідентичність ще не налаштована).
83+
pub fn get_identity() -> Option<String> {
84+
let handle = read_own_config().get("identity")?.as_str()?.to_string();
85+
(!handle.is_empty()).then_some(handle)
86+
}
87+
88+
/// Зберігає handle власника (порожній — скидає ідентичність).
89+
pub fn set_identity(handle: String) -> Result<(), String> {
90+
merge_own_config("identity", json!(handle.trim()))
91+
}
92+
6193
#[cfg(test)]
6294
mod tests {
6395
use super::*;

owner/src-tauri/src/docs/config.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ type: Rust Module
33
title: config.rs
44
resource: owner/src-tauri/src/config.rs
55
docgen:
6-
crc: b075356c
6+
crc: 9ab105c1
77
model: omlx/gemma-4-e4b-it-OptiQ-4bit
8-
tier: local-min-retry
98
score: 85
109
issues: anchor-miss:absent.json,anchor-miss:empty.json,anchor-miss:full.json,best-of-2:retry-won,judge:inaccurate:0.98
1110
judgeModel: openai-codex/gpt-5.4-mini

owner/src-tauri/src/docs/lib.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ type: Rust Module
33
title: lib.rs
44
resource: owner/src-tauri/src/lib.rs
55
docgen:
6-
crc: b9192bb9
6+
crc: 54d77e94
77
model: openai-codex/gpt-5.4-mini
88
score: 100
99
issues: judge:inaccurate:0.99

owner/src-tauri/src/lib.rs

Lines changed: 190 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,30 +45,159 @@ fn create_task(tasks_dir: String, name: String, opts: CreateOpts) -> Result<Crea
4545
mt_core::create_task(tasks_dir, name, opts)
4646
}
4747

48+
/// Зарезервовані метаключі `autonomy.yml` поза словником класів дій
49+
/// (M5, спека 260714-cognitive-delegation): `owner` — handle власника
50+
/// піддерева (як `assignee` у h.md, PII поза git), `since` — дата
51+
/// делегування. Живуть у тому самому поліс-файлі, бо власник і конверт
52+
/// автономії народжуються одним актом делегування.
53+
const RESERVED_KEYS: [&str; 2] = ["owner", "since"];
54+
4855
/// Валідує `autonomy.yml` (M3): непорожні рядки без `#` — мають бути
49-
/// `клас: auto|approve`. Дзеркалить парсер owner/src/autonomy.js — файл
50-
/// не входить у контракт mt-core (a.md перезаписується цілком при зміні
51-
/// виконавця), тож owner сам відповідає за цілісність свого шару.
56+
/// `клас: auto|approve` або зарезервований метаключ із непорожнім
57+
/// значенням. Дзеркалить парсер owner/src/autonomy.js — файл не входить
58+
/// у контракт mt-core (a.md перезаписується цілком при зміні виконавця),
59+
/// тож owner сам відповідає за цілісність свого шару.
5260
fn validate_autonomy(yaml: &str) -> Result<(), String> {
5361
for line in yaml.lines() {
5462
let line = line.trim();
5563
if line.is_empty() || line.starts_with('#') {
5664
continue;
5765
}
58-
let (key, gate) = line
66+
let (key, value) = line
5967
.split_once(':')
6068
.ok_or_else(|| format!("autonomy.yml: невалідний рядок {line:?}"))?;
61-
let gate = gate.trim();
62-
if gate != "auto" && gate != "approve" {
69+
let (key, value) = (key.trim(), value.trim());
70+
if RESERVED_KEYS.contains(&key) {
71+
if value.is_empty() || value.split_whitespace().count() != 1 {
72+
return Err(format!(
73+
"autonomy.yml: {key} — має бути один токен без пробілів, отримано {value:?}"
74+
));
75+
}
76+
continue;
77+
}
78+
if value != "auto" && value != "approve" {
6379
return Err(format!(
64-
"autonomy.yml: клас {} — невідомий гейт {gate:?}",
65-
key.trim()
80+
"autonomy.yml: клас {key} — невідомий гейт {value:?}"
6681
));
6782
}
6883
}
6984
Ok(())
7085
}
7186

87+
/// Handle власника з тексту `autonomy.yml` (None — рядка `owner:` немає).
88+
fn parse_owner(yaml: &str) -> Option<String> {
89+
for line in yaml.lines() {
90+
let line = line.trim();
91+
if line.is_empty() || line.starts_with('#') {
92+
continue;
93+
}
94+
if let Some((key, value)) = line.split_once(':') {
95+
if key.trim() == "owner" && !value.trim().is_empty() {
96+
return Some(value.trim().to_string());
97+
}
98+
}
99+
}
100+
None
101+
}
102+
103+
/// Рекурсивно збирає `owner:`-розмітку лісу: тека вузла (містить task.md)
104+
/// з autonomy.yml → запис `шлях вузла → handle`. Приховані теки
105+
/// (`.worktrees` тощо) пропускаються.
106+
fn collect_owners(root: &Path, dir: &Path, owners: &mut std::collections::HashMap<String, String>) {
107+
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
108+
let path = entry.path();
109+
if !path.is_dir()
110+
|| path
111+
.file_name()
112+
.and_then(|n| n.to_str())
113+
.is_none_or(|n| n.starts_with('.'))
114+
{
115+
continue;
116+
}
117+
if path.join("task.md").is_file() {
118+
if let Ok(yaml) = fs::read_to_string(path.join("autonomy.yml")) {
119+
if let Some(owner) = parse_owner(&yaml) {
120+
if let Ok(rel) = path.strip_prefix(root) {
121+
owners.insert(rel.to_string_lossy().replace('\\', "/"), owner);
122+
}
123+
}
124+
}
125+
}
126+
collect_owners(root, &path, owners);
127+
}
128+
}
129+
130+
/// `owner:`-розмітка воркспейсу одним проходом ФС — сировина scope-деривації
131+
/// на фронті (порожня мапа = нерозмічений ліс, single-owner поведінка).
132+
#[tauri::command]
133+
fn scan_owners(tasks_dir: String) -> Result<std::collections::HashMap<String, String>, String> {
134+
let root = PathBuf::from(&tasks_dir);
135+
if !root.is_dir() {
136+
return Err(format!("tasks dir not found: {tasks_dir}"));
137+
}
138+
let mut owners = std::collections::HashMap::new();
139+
collect_owners(&root, &root, &mut owners);
140+
Ok(owners)
141+
}
142+
143+
/// Handle власника застосунку (None — «Хто ти» ще не пройдено).
144+
#[tauri::command]
145+
fn get_identity() -> Option<String> {
146+
config::get_identity()
147+
}
148+
149+
/// Зберігає handle власника у локальний конфіг (PII лишається поза git).
150+
#[tauri::command]
151+
fn set_identity(handle: String) -> Result<(), String> {
152+
config::set_identity(handle)
153+
}
154+
155+
/// Ефективний власник вузла з розмітки: найдовший префікс шляху,
156+
/// що має `owner:` (фрактальне успадкування за графом задач).
157+
fn effective_owner_of<'a>(
158+
owners: &'a std::collections::HashMap<String, String>,
159+
task_path: &str,
160+
) -> Option<&'a str> {
161+
let segments: Vec<&str> = task_path.split('/').collect();
162+
(1..=segments.len())
163+
.rev()
164+
.find_map(|i| owners.get(&segments[..i].join("/")).map(String::as_str))
165+
}
166+
167+
/// Fail-closed гейт власності write-дій (чиста логіка, M5): у розміченому
168+
/// лісі діяти на вузлі може лише його effective owner; вузол без власника —
169+
/// «нічия земля», діяти може будь-хто (інакше осиротіла гілка блокується
170+
/// назавжди). Нерозмічений ліс — single-owner поведінка без перевірки.
171+
fn check_scope(
172+
owners: &std::collections::HashMap<String, String>,
173+
task_path: &str,
174+
me: Option<&str>,
175+
) -> Result<(), String> {
176+
if owners.is_empty() {
177+
return Ok(());
178+
}
179+
let Some(owner) = effective_owner_of(owners, task_path) else {
180+
return Ok(());
181+
};
182+
let me = me.ok_or(
183+
"ліс розмічений власниками, а твоя ідентичність не налаштована — виконай set_identity",
184+
)?;
185+
if owner == me {
186+
Ok(())
187+
} else {
188+
Err(format!(
189+
"вузол {task_path} належить {owner} — поза твоїм скоупом (ескалація/делегування — M6)"
190+
))
191+
}
192+
}
193+
194+
/// Гейт власності для tauri-команд: розмітка читається з ФС, ідентичність —
195+
/// з конфігу застосунку.
196+
fn assert_owned(tasks_dir: &str, task_path: &str) -> Result<(), String> {
197+
let owners = scan_owners(tasks_dir.to_string())?;
198+
check_scope(&owners, task_path, config::get_identity().as_deref())
199+
}
200+
72201
/// Власна політика вузла (порожній рядок — файлу немає, повне успадкування).
73202
#[tauri::command]
74203
fn read_autonomy(tasks_dir: String, task_path: String) -> Result<String, String> {
@@ -89,6 +218,7 @@ fn write_autonomy(tasks_dir: String, task_path: String, yaml: String) -> Result<
89218
return Err(format!("node not found: {task_path}"));
90219
}
91220
validate_autonomy(&yaml)?;
221+
assert_owned(&tasks_dir, &task_path)?;
92222
fs::write(dir.join("autonomy.yml"), yaml).map_err(|e| e.to_string())
93223
}
94224

@@ -128,6 +258,7 @@ fn draft_plan(
128258
if !dir.join("task.md").is_file() {
129259
return Err(format!("node not found: {task_path}"));
130260
}
261+
assert_owned(&tasks_dir, &task_path)?;
131262
let children = mt_core::spawn::parse_children(&children_yaml)?;
132263
if children.is_empty() {
133264
return Err("## Children порожня — плановик не дав жодної дитини".to_string());
@@ -170,12 +301,14 @@ fn spawn_approve(
170301
tasks_dir: String,
171302
task_path: String,
172303
) -> Result<mt_core::spawn::SpawnOutcome, String> {
304+
assert_owned(&tasks_dir, &task_path)?;
173305
mt_core::spawn::spawn_approve(&tasks_dir, &task_path)
174306
}
175307

176308
/// Вердикт власника: reject плану з причиною → plan-rejected_NNN.md.
177309
#[tauri::command]
178310
fn spawn_reject(tasks_dir: String, task_path: String, reason: String) -> Result<String, String> {
311+
assert_owned(&tasks_dir, &task_path)?;
179312
mt_core::spawn::spawn_reject(&tasks_dir, &task_path, &reason)
180313
}
181314

@@ -187,6 +320,7 @@ fn human_done(
187320
task_path: String,
188321
summary: String,
189322
) -> Result<mt_core::signal::SignalOutcome, String> {
323+
assert_owned(&tasks_dir, &task_path)?;
190324
if let Err(e) = mt_core::signal::write_fact(&tasks_dir, &task_path, &summary, None) {
191325
// Порожній/битий Summary — справжня помилка; наявний fact — ні
192326
// (retry після Check-фейлу не перетирає його).
@@ -241,6 +375,9 @@ pub fn run() {
241375
read_task,
242376
read_autonomy,
243377
write_autonomy,
378+
scan_owners,
379+
get_identity,
380+
set_identity,
244381
plan_review_info,
245382
spawn_approve,
246383
spawn_reject,
@@ -375,4 +512,49 @@ mod tests {
375512
)
376513
.is_err());
377514
}
515+
516+
#[test]
517+
fn autonomy_accepts_reserved_owner_and_since() {
518+
assert!(validate_autonomy("owner: olena\nsince: 2026-07-13\ndeploy: approve\n").is_ok());
519+
// owner з пробілами / порожній — відмова (handle — один токен)
520+
assert!(validate_autonomy("owner: Олена Коваль\n").is_err());
521+
assert!(validate_autonomy("owner:\n").is_err());
522+
}
523+
524+
#[test]
525+
fn scan_owners_collects_marked_nodes_recursively() {
526+
let (tmp, node) = goal_node();
527+
let child = Path::new(&node).join("collect");
528+
fs::create_dir_all(&child).unwrap();
529+
fs::write(child.join("task.md"), "---\nschema_version: 1\n---\n").unwrap();
530+
fs::write(child.join("autonomy.yml"), "owner: olena\n").unwrap();
531+
// autonomy.yml без owner — не розмітка
532+
fs::write(Path::new(&node).join("autonomy.yml"), "deploy: approve\n").unwrap();
533+
534+
let owners = scan_owners(tmp.path().to_string_lossy().into_owned()).unwrap();
535+
assert_eq!(owners.len(), 1);
536+
assert_eq!(
537+
owners.get("goal/collect").map(String::as_str),
538+
Some("olena")
539+
);
540+
}
541+
542+
#[test]
543+
fn check_scope_unmarked_forest_allows_everyone() {
544+
let owners = std::collections::HashMap::new();
545+
assert!(check_scope(&owners, "goal", None).is_ok());
546+
}
547+
548+
#[test]
549+
fn check_scope_enforces_effective_owner_with_inheritance() {
550+
let owners = std::collections::HashMap::from([("goal".to_string(), "olena".to_string())]);
551+
// дитина успадковує власника предка
552+
assert!(check_scope(&owners, "goal/collect", Some("olena")).is_ok());
553+
let err = check_scope(&owners, "goal/collect", Some("vkozlov")).unwrap_err();
554+
assert!(err.contains("olena"));
555+
// розмічений ліс без ідентичності — fail-closed
556+
assert!(check_scope(&owners, "goal", None).is_err());
557+
// «нічия земля»: вузол поза розміченими піддеревами — діяти можна
558+
assert!(check_scope(&owners, "orphan", Some("vkozlov")).is_ok());
559+
}
378560
}

0 commit comments

Comments
 (0)