@@ -68,6 +68,8 @@ import {
6868 acknowledgeTaskEvents ,
6969 snapshotTaskEvents ,
7070 collectAndSpoolTaskEvents ,
71+ normalizeTaskBody ,
72+ TRIAGE_TAG ,
7173} from '@reneza/ats-core' ;
7274import { scaffoldAdapter } from '../scaffold.js' ;
7375import { runDoctor , formatDoctor } from '../doctor.js' ;
@@ -205,6 +207,9 @@ async function main() {
205207 case 'bench' :
206208 handleBench ( ) ;
207209 return ;
210+ case 'fmt' :
211+ handleFmt ( ) ;
212+ return ;
208213 case 'sync' :
209214 result = await handleSync ( ) ;
210215 break ;
@@ -365,7 +370,7 @@ function helpFor(command) {
365370const COMPLETION_COMMANDS = [
366371 'setup' , 'find' , 'open' , 'get' , 'url' , 'links' , 'create' , 'update' , 'hybrid' , 'similar' ,
367372 'intent' , 'promote' , 'hierarchy' , 'lifecycle' , 'link' , 'reference' , 'relate' , 'graph' , 'context' , 'ledger' , 'security' , 'events' ,
368- 'doctor' , 'status' , 'cache' , 'bench' , 'sync' , 'adapter' , 'init' , 'config' , 'auth' ,
373+ 'doctor' , 'status' , 'cache' , 'bench' , 'fmt' , ' sync', 'adapter' , 'init' , 'config' , 'auth' ,
369374 'projects' , 'tasks' , 'notes' , 'help' , 'completion' ,
370375] ;
371376
@@ -647,6 +652,96 @@ function auditCliWrite(action, result, fallback, metadata, advanced = false) {
647652 }
648653}
649654
655+ // Cheap-model triage classifier (route/type/model/effort/do tags). Shells the
656+ // shared beads-triage.py in --emit-json mode so the LLM prompt has ONE source of
657+ // truth. Returns {tags:[], next:''} or null on any failure (caller degrades to
658+ // structure-only). No write happens here — the caller persists once.
659+ // Default location is Rene's checkout; override with ATS_TRIAGE_BIN. runTriageEmit's
660+ // existsSync guard means triage is simply skipped where the script isn't present.
661+ const TRIAGE_BIN = process . env . ATS_TRIAGE_BIN || path . join ( os . homedir ( ) , 'claude' , 'beads-triage.py' ) ;
662+
663+ // Cost caps mirror beads-triage.py: (1) PER-CALL — the shared classify() already
664+ // passes `--max-budget-usd 0.05`, inherited here automatically. (2) VOLUME — the cron
665+ // caps tasks/run (TRIAGE_MAX_PER_RUN) to protect the shared 5h budget from bursts;
666+ // get processes one task per call, so the analog is a rolling DAILY cap on get-triage
667+ // Haiku calls. Past the cap, get degrades to structure-only normalize (no loss — the
668+ // task still conforms and gets tagged on a later get or by the batch cron). 0 disables.
669+ const GET_TRIAGE_MAX_PER_DAY = parseInt ( process . env . ATS_GET_TRIAGE_MAX_PER_DAY || '25' , 10 ) ;
670+ function getTriageBudgetFile ( ) {
671+ return path . join ( process . env . XDG_CONFIG_HOME || path . join ( os . homedir ( ) , '.config' ) , 'ats' , 'get-triage-budget.json' ) ;
672+ }
673+ function claimGetTriageBudget ( ) {
674+ if ( ! ( GET_TRIAGE_MAX_PER_DAY > 0 ) ) return false ;
675+ const day = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
676+ const file = getTriageBudgetFile ( ) ;
677+ let st ;
678+ try { st = JSON . parse ( fs . readFileSync ( file , 'utf8' ) ) ; } catch { st = { } ; }
679+ if ( st . day !== day ) st = { day, count : 0 } ;
680+ if ( st . count >= GET_TRIAGE_MAX_PER_DAY ) return false ;
681+ st . count += 1 ;
682+ try { fs . mkdirSync ( path . dirname ( file ) , { recursive : true } ) ; fs . writeFileSync ( file , JSON . stringify ( st ) ) ; } catch { /* best-effort */ }
683+ return true ;
684+ }
685+
686+ function runTriageEmit ( task ) {
687+ try {
688+ if ( ! fs . existsSync ( TRIAGE_BIN ) ) return null ;
689+ const env = { ...process . env } ;
690+ delete env . CLAUDECODE ; delete env . CLAUDE_CODE_ENTRYPOINT ;
691+ const r = spawnSync ( 'python3' , [ TRIAGE_BIN , '--emit-json' ] , {
692+ input : JSON . stringify ( [ task ] ) , encoding : 'utf8' , timeout : 60000 , env,
693+ } ) ;
694+ if ( r . status !== 0 || ! r . stdout ) return null ;
695+ const line = r . stdout . trim ( ) . split ( '\n' ) . filter ( Boolean ) . pop ( ) ;
696+ const d = JSON . parse ( line ) ;
697+ return { tags : d . tags || [ ] , next : ( d . next || '' ) . trim ( ) } ;
698+ } catch { return null ; }
699+ }
700+
701+ // Single explicit `ats tasks get PROJECT_ID TASK_ID` = "the task at hand" → read
702+ // the context once, normalize the Goal+Log body, classify triage tags, and persist
703+ // BOTH in one write. Skips the LLM call when the task is already triaged AND its
704+ // body is already conforming (freshness guard). Never fires for find/list/search.
705+ async function formatTriageOnGet ( t , adapter , proj , id , task ) {
706+ const fullProj = task . fullProjectId || task . projectId || proj ;
707+ const fullId = task . fullId || task . id || id ;
708+ const curTags = task . tags || [ ] ;
709+ const norm = normalizeTaskBody ( task . content || '' ) ;
710+ const hasTriage = curTags . some ( ( x ) => TRIAGE_TAG . test ( x ) ) ;
711+ const skip = args . options [ 'no-triage' ] === true || process . env . ATS_GET_NOTRIAGE ;
712+
713+ let newTags = null ;
714+ let next = '' ;
715+ if ( ! skip && ( ! hasTriage || norm . changed ) ) {
716+ if ( claimGetTriageBudget ( ) ) {
717+ const r = runTriageEmit ( { id : fullId , projectId : fullProj , title : task . title , content : norm . content } ) ;
718+ if ( r ) { newTags = r . tags ; next = r . next ; }
719+ } else {
720+ process . stderr . write ( `[ats] get-triage daily cap (${ GET_TRIAGE_MAX_PER_DAY } ) reached — structure-only this read; triage deferred to the batch cron.\n` ) ;
721+ }
722+ }
723+ const finalBody = next ? normalizeTaskBody ( norm . content , { next } ) . content : norm . content ;
724+ const bodyChanged = finalBody . trim ( ) !== ( task . content || '' ) . trim ( ) ;
725+ if ( ! bodyChanged && ! newTags ) return task ; // already conforming + tagged → no write
726+
727+ const patch = { content : finalBody } ;
728+ if ( newTags ) patch . tags = [ ...curTags . filter ( ( x ) => ! TRIAGE_TAG . test ( x ) ) , ...newTags ] ;
729+ const res = t ?. update
730+ ? await t . update ( proj , id , patch )
731+ : await adapter . updateTask ( proj , id , { ...patch , tags : tagsToArray ( patch . tags ) } ) ;
732+ auditCliWrite ( 'task.updated' , res , { projectId : proj , taskId : id } , { fields : Object . keys ( patch ) , via : 'get-format' } ) ;
733+ return res . task || res ;
734+ }
735+
736+ // `ats fmt [--next "..."]` — pure stdin→stdout Goal+Log normalizer (no adapter,
737+ // no network). Lets other tools (e.g. beads-triage.py) reuse the one normalizer.
738+ function handleFmt ( ) {
739+ let body ;
740+ try { body = fs . readFileSync ( 0 , 'utf8' ) ; } catch { body = '' ; }
741+ const { content } = normalizeTaskBody ( body , { next : args . options . next || '' } ) ;
742+ process . stdout . write ( content ) ;
743+ }
744+
650745async function handleTasks ( ) {
651746 const adapter = await loadAdapter ( ) ;
652747 const t = adapter . __ext ?. tasks ; // optional: rich adapters (TickTick) provide it
@@ -655,9 +750,26 @@ async function handleTasks() {
655750 case 'list' :
656751 if ( ! args . positional [ 0 ] ) { console . error ( 'Usage: ats tasks list PROJECT_ID' ) ; process . exit ( 1 ) ; }
657752 return t ?. list ? await t . list ( args . positional [ 0 ] ) : await adapter . listTasksInProject ( args . positional [ 0 ] ) ;
658- case 'get' :
753+ case 'get' : {
659754 if ( ! args . positional [ 0 ] || ! args . positional [ 1 ] ) { console . error ( 'Usage: ats tasks get PROJECT_ID TASK_ID' ) ; process . exit ( 1 ) ; }
660- return t ?. get ? await t . get ( args . positional [ 0 ] , args . positional [ 1 ] ) : await adapter . getTask ( args . positional [ 0 ] , args . positional [ 1 ] ) ;
755+ const [ gp , gid ] = args . positional ;
756+ const got = t ?. get ? await t . get ( gp , gid ) : await adapter . getTask ( gp , gid ) ;
757+ if ( args . options [ 'no-format' ] === true || process . env . ATS_GET_NOFORMAT ) return got ;
758+ return await formatTriageOnGet ( t , adapter , gp , gid , got ) ;
759+ }
760+ case 'normalize' : {
761+ // Structure-only backfill (no triage/LLM): lift Goal, ensure Log, preserve Notes.
762+ if ( ! args . positional [ 0 ] || ! args . positional [ 1 ] ) { console . error ( 'Usage: ats tasks normalize PROJECT_ID TASK_ID' ) ; process . exit ( 1 ) ; }
763+ const [ np , nid ] = args . positional ;
764+ const task = t ?. get ? await t . get ( np , nid ) : await adapter . getTask ( np , nid ) ;
765+ const norm = normalizeTaskBody ( task . content || '' ) ;
766+ if ( ! norm . changed ) return { task : { projectId : np , taskId : nid } , changed : false } ;
767+ const res = t ?. update
768+ ? await t . update ( np , nid , { content : norm . content } )
769+ : await adapter . updateTask ( np , nid , { content : norm . content } ) ;
770+ auditCliWrite ( 'task.updated' , res , { projectId : np , taskId : nid } , { fields : [ 'content' ] , via : 'normalize' } ) ;
771+ return res . task || res ;
772+ }
661773 case 'create' : {
662774 let projectId = args . options . project || '' ;
663775 let title = args . positional [ 0 ] ;
@@ -686,6 +798,9 @@ async function handleTasks() {
686798 console . error ( 'Run without arguments for interactive mode.' ) ;
687799 process . exit ( 1 ) ;
688800 }
801+ // Conform any body that's written (deterministic, no LLM). Bare quick-captures
802+ // (no --content) stay clean; they get the Goal+Log skeleton on first `get`.
803+ if ( opts . content ) opts . content = normalizeTaskBody ( opts . content ) . content ;
689804 const result = t ?. create
690805 ? await t . create ( projectId , title , opts )
691806 : await adapter . createTask ( {
@@ -726,6 +841,8 @@ async function handleTasks() {
726841 tags : args . options . tags ,
727842 reminder : args . options . reminder ,
728843 } ;
844+ // Normalize the body whenever content is being written (no extra fetch when it isn't).
845+ if ( patch . content !== undefined ) patch . content = normalizeTaskBody ( patch . content ) . content ;
729846 const result = t ?. update
730847 ? await t . update ( args . positional [ 0 ] , args . positional [ 1 ] , patch )
731848 : await adapter . updateTask ( args . positional [ 0 ] , args . positional [ 1 ] , { ...patch , tags : tagsToArray ( patch . tags ) } ) ;
0 commit comments