@@ -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 сам відповідає за цілісність свого шару.
5260fn 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]
74203fn 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]
178310fn 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\n since: 2026-07-13\n deploy: 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" ) , "---\n schema_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