dsh-tacit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/service.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  bootstrapArgSchema,
33
33
  usageArgSchema,
34
34
  usageRunArgSchema,
35
+ directiveReceiptArgSchema,
35
36
  } from './schema.js'
36
37
  import { dayKey } from './store.js'
37
38
  import { createUsageTracker, totalTokens } from './usage.js'
@@ -71,21 +72,26 @@ import {
71
72
  ANALYSIS_TOOL,
72
73
  IMPROVE_TOOL,
73
74
  DISTILL_TOOL,
75
+ POLICY_RE,
74
76
  clipSafe,
75
77
  isMessyTurn,
76
78
  looksLikeCorrection,
77
79
  looksLikeContinuation,
80
+ looksLikeJobNotification,
78
81
  classifyDirectives,
79
82
  DIRECTIVE_SYSTEM_PROMPT,
80
83
  DIRECTIVE_TOOL,
81
84
  DIRECTIVE_MAX_TOKENS,
82
85
  DIRECTIVE_TIMEOUT_MS,
83
- MAX_DIRECTIVES,
84
86
  buildDirectiveUserText,
87
+ directiveLabels,
85
88
  buildSteeringSection,
86
89
  renderSteeringSection,
87
- workspaceLabel,
88
- MAX_WORKSPACE_DIRECTIVES,
90
+ scopeOf,
91
+ capDirectives,
92
+ mergeDirectives,
93
+ isDeadDirective,
94
+ MAX_DIRECTIVE_EVIDENCE,
89
95
  ENRICH_SYSTEM_PROMPT,
90
96
  ENRICH_TOOL,
91
97
  ENRICH_MAX_TOKENS,
@@ -96,7 +102,9 @@ import {
96
102
  buildEnrichUserText,
97
103
  normalizeEnrichNote,
98
104
  computeTrend,
105
+ markCorrections,
99
106
  } from './analyze.js'
107
+ import { normalizeWorkspace, workspaceContains, workspaceLabel, workspaceLabels } from './workspace.js'
100
108
 
101
109
  /** In-memory rewrite ledger bounds (never persisted). */
102
110
  const MAX_REWRITE_RECORDS = 50
@@ -160,12 +168,13 @@ function turnsOf(service, sessionId) {
160
168
  }
161
169
  }
162
170
 
163
- /** The absolute workspace directory a session was created in, else undefined. */
171
+ /** The normalised workspace directory a session was created in, else undefined. */
164
172
  function cwdOf(session) {
165
173
  const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
166
174
  ? session.header.cwd
167
175
  : undefined
168
- return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined
176
+ const workspace = normalizeWorkspace(cwd)
177
+ return workspace.length > 0 ? workspace : undefined
169
178
  }
170
179
 
171
180
  /** A human label for a session: the workspace directory's basename, else ''. */
@@ -177,32 +186,30 @@ function sessionLabelOf(service, sessionId) {
177
186
  /** Every distinct workspace among the live sessions, labelled for the UI. */
178
187
  function listWorkspaces(service) {
179
188
  const sessions = typeof service.sessions?.list === 'function' ? service.sessions.list() : []
180
- const seen = new Map()
181
- for (const session of Array.isArray(sessions) ? sessions : []) {
182
- const cwd = cwdOf(session)
183
- if (cwd !== undefined && !seen.has(cwd)) seen.set(cwd, { cwd, label: workspaceLabel(cwd) })
184
- }
185
- return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
189
+ const cwds = (Array.isArray(sessions) ? sessions : []).map(cwdOf).filter((cwd) => cwd !== undefined)
190
+ return [...workspaceLabels(cwds)]
191
+ .map(([cwd, label]) => ({ cwd, label }))
192
+ .sort((a, b) => a.label.localeCompare(b.label))
186
193
  }
187
194
 
188
- /** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
189
- function capDirectives(list) {
190
- const counts = new Map()
191
- const out = []
192
- for (const entry of list) {
193
- const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
194
- const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
195
- const n = counts.get(scope) ?? 0
196
- if (n >= limit) continue
197
- counts.set(scope, n + 1)
198
- out.push(entry)
199
- }
200
- return out
195
+ /** The `workspaceSeenAt` map narrowed to the workspaces a directive still points at. */
196
+ function pruneSeenAt(seenAt, directives) {
197
+ const scopes = new Set(directives.map(scopeOf))
198
+ return Object.fromEntries(Object.entries(seenAt).filter(([scope]) => scopes.has(scope)))
201
199
  }
202
200
 
203
- /** Short, secret-free context digest of a session's last two finished turns. */
201
+ /**
202
+ * Bounded context of the last two turns the user actually wrote, read from the
203
+ * digest. Background-job notifications and bare continuations are finished
204
+ * turns too, and one of them landing after the last real exchange would
205
+ * otherwise hide the facts the rewrite needs.
206
+ */
204
207
  function recentContextOf(turns) {
205
- const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true).slice(-2)
208
+ const written = (turn) => {
209
+ const prompt = typeof turn.prompt === 'string' ? turn.prompt.trim() : ''
210
+ return prompt.length > 0 && !looksLikeJobNotification(prompt) && !looksLikeContinuation(prompt)
211
+ }
212
+ const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true && written(turn)).slice(-2)
206
213
  if (finished.length === 0) return ''
207
214
  return finished.map((turn) => {
208
215
  const prompt = typeof turn.prompt === 'string' ? turn.prompt.slice(0, 600) : ''
@@ -265,6 +272,9 @@ function coachErrorCode(error) {
265
272
  const text = raw + ' ' + message
266
273
  if (/abort|timeout/i.test(text)) return 'timeout'
267
274
  if (/auth|401|403|api[ _-]?key|key not/i.test(text)) return 'no-api-key'
275
+ // Ahead of the rate-limit rule: an exhausted balance says "quota" too, but
276
+ // it will not clear by waiting, so it must not read as "try again shortly".
277
+ if (/\binsufficient[ _-]?(quota|balance|credit)|\bexceeded your current quota/i.test(text)) return 'no-credit'
268
278
  // Word-bounded: a bare /rate/ matches the "rate" inside "generate", and the
269
279
  // ladder now sees the raw code and message of every provider failure. A
270
280
  // trailing \b after "quota" would not do here — `_` is a word character, so
@@ -310,9 +320,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
310
320
 
311
321
  const safeProfile = () => {
312
322
  const parsed = profileSchema.safeParse(store.profile())
313
- return parsed.success
314
- ? parsed.data
315
- : { analyzedCount: 0, patterns: [], updatedAt: 0, styleRules: [], feedbackLog: [], pendingDistill: 0, directives: [], analysesSinceDirectives: 0 }
323
+ if (!parsed.success) {
324
+ return { analyzedCount: 0, patterns: [], updatedAt: 0, styleRules: [], feedbackLog: [], pendingDistill: 0, directives: [], analysesSinceDirectives: 0, workspaceSeenAt: {} }
325
+ }
326
+ for (const entry of parsed.data.directives) {
327
+ const workspace = normalizeWorkspace(entry.workspace)
328
+ if (workspace.length > 0) entry.workspace = workspace
329
+ else delete entry.workspace
330
+ }
331
+ return parsed.data
316
332
  }
317
333
  let directiveSeq = 0
318
334
  const nextDirectiveId = () => {
@@ -321,7 +337,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
321
337
  }
322
338
  let directivesInFlight = false
323
339
  /** Steering `{ text, ids }` frozen per live session object (keeps the model's prefix cache stable within a session). */
324
- const steeringFrozen = new WeakMap()
340
+ let steeringFrozen = new WeakMap()
341
+ /** Forget every open session's frozen steering: the next assembly re-freezes from the profile as it is now. */
342
+ const invalidateSteering = () => { steeringFrozen = new WeakMap() }
325
343
 
326
344
  const nextRewriteId = () => {
327
345
  rewriteSeq += 1
@@ -351,13 +369,24 @@ export function createCoachService(ctx, store, effectiveConfig) {
351
369
  return profile
352
370
  }
353
371
 
372
+ /** The normalised workspace of every session loaded right now. */
373
+ const liveCwds = () => listWorkspaces(serviceOf(ctx)).map((entry) => entry.cwd)
374
+
354
375
  /** Bound every v2 field, validate, persist; returns the stored profile. */
355
376
  const capAndSaveProfile = (profile) => {
356
377
  const config = effectiveConfig()
357
378
  profile.patterns = profile.patterns.slice(0, config.maxPatterns)
358
379
  profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
359
380
  profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
360
- profile.directives = capDirectives(profile.directives)
381
+ const cwds = liveCwds()
382
+ const seenAt = profile.workspaceSeenAt ?? {}
383
+ for (const entry of profile.directives) {
384
+ const scope = scopeOf(entry)
385
+ if (scope !== '' && cwds.some((cwd) => workspaceContains(scope, cwd))) seenAt[scope] = Date.now()
386
+ }
387
+ profile.workspaceSeenAt = pruneSeenAt(seenAt, profile.directives)
388
+ profile.directives = capDirectives(profile.directives, { seenAt: profile.workspaceSeenAt })
389
+ profile.workspaceSeenAt = pruneSeenAt(profile.workspaceSeenAt, profile.directives)
361
390
  profile.updatedAt = Date.now()
362
391
  const validated = profileSchema.parse(profile)
363
392
  store.saveProfile(validated)
@@ -437,26 +466,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
437
466
  handleVerification(sessionId, turns)
438
467
  }
439
468
 
440
- /** Finished turns already counted toward directive trials (sessionId:turn). */
469
+ /** Finished turns already counted toward directive trials, and turns already counted as corrected (sessionId:turn). */
441
470
  const seenFinished = new Set()
471
+ const seenCorrected = new Set()
442
472
 
443
473
  const pct = (rate) => String(Math.round(rate * 100)) + '%'
444
474
 
445
475
  /**
446
476
  * Directive trials ride the same free feed: every NEW finished turn counts
447
477
  * toward each candidate that was actually in that session's frozen steering
448
- * text; after `directiveTrialTurns` such turns the candidate is activated,
449
- * or retired when the messy rate rose past the baseline by more than
450
- * `directiveWorseBy`. A session whose steering was never assembled here
451
- * (started before the candidate existed, or before a restart) counts toward
452
- * nobody — its turns say nothing about the candidate.
478
+ * text, and so does every correction of such a turn (the session's next
479
+ * prompt, known the moment it starts). After `directiveTrialTurns` turns the
480
+ * candidate is retired when its correction rate rose past the baseline by
481
+ * more than `directiveWorseBy` (or its messy rate by twice that), otherwise
482
+ * activated. A session whose steering was never assembled here (started
483
+ * before the candidate existed, or before a restart) counts toward nobody —
484
+ * its turns say nothing about the candidate.
453
485
  */
454
486
  const recordTrialTurns = (sessionId, turns) => {
455
- const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
456
- && turn.finished === true && typeof turn.turn === 'number' && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt
457
- && !seenFinished.has(sessionId + ':' + turn.turn))
458
- if (fresh.length === 0) return
459
- for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
487
+ const key = (turn) => sessionId + ':' + turn.turn
488
+ const counted = markCorrections(turns).filter((turn) => typeof turn.turn === 'number' && turn.finished === true
489
+ && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt)
490
+ const fresh = counted.filter((turn) => !seenFinished.has(key(turn)))
491
+ const corrected = counted.filter((turn) => turn.corrected && !seenCorrected.has(key(turn)))
492
+ for (const turn of fresh) seenFinished.add(key(turn))
493
+ for (const turn of corrected) seenCorrected.add(key(turn))
494
+ if (fresh.length === 0 && corrected.length === 0) return
460
495
  const steered = steeringIdsBySession.get(sessionId)
461
496
  if (steered === undefined || steered.length === 0) return
462
497
  const profile = safeProfile()
@@ -464,21 +499,35 @@ export function createCoachService(ctx, store, effectiveConfig) {
464
499
  if (candidates.length === 0) return
465
500
  const config = effectiveConfig()
466
501
  const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
502
+ let verdicts = 0
467
503
  for (const entry of candidates) {
468
- entry.trial.turns += fresh.length
469
- entry.trial.messy += messyCount
470
- if (entry.trial.turns < config.directiveTrialTurns) continue
471
- const rate = entry.trial.messy / entry.trial.turns
472
- if (rate > entry.trial.baselineRate + config.directiveWorseBy) {
504
+ const trial = entry.trial
505
+ if (trial.baselineCorrectionRate < 0) trial.baselineCorrectionRate = baselinesFor(scopeOf(entry)).baselineCorrectionRate
506
+ trial.turns += fresh.length
507
+ trial.messy += messyCount
508
+ trial.corrected += corrected.length
509
+ if (trial.turns < config.directiveTrialTurns) continue
510
+ const correctionRate = trial.corrected / trial.turns
511
+ const messyRate = trial.messy / trial.turns
512
+ const worse = correctionRate > trial.baselineCorrectionRate + config.directiveWorseBy
513
+ ? 'corrections rose ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate)
514
+ : messyRate > trial.baselineMessyRate + 2 * config.directiveWorseBy
515
+ ? 'messy turns rose ' + pct(trial.baselineMessyRate) + ' → ' + pct(messyRate)
516
+ : null
517
+ verdicts += 1
518
+ entry.evaluatedAt = Date.now()
519
+ entry.updatedAt = entry.evaluatedAt
520
+ if (worse !== null) {
473
521
  entry.status = 'retired'
474
522
  entry.enabled = false
475
- entry.retiredReason = 'messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + ' while active'
523
+ entry.retiredReason = worse + ' during its trial'
476
524
  console.info('[tacit] retired directive (' + entry.retiredReason + '): ' + entry.text)
477
525
  } else {
478
526
  entry.status = 'active'
479
- console.info('[tacit] activated directive (messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + '): ' + entry.text)
527
+ console.info('[tacit] activated directive (corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate) + '): ' + entry.text)
480
528
  }
481
529
  }
530
+ if (verdicts > 0) startNextTrial(profile)
482
531
  capAndSaveProfile(profile)
483
532
  }
484
533
 
@@ -519,7 +568,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
519
568
  return earlier.length > 0 ? earlier[earlier.length - 1] : null
520
569
  }
521
570
 
522
- const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
571
+ const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '', force = false } = {}) => {
523
572
  const profile = safeProfile()
524
573
  const key = `${sessionId}:${turn}`
525
574
  /** The run this analysis is billed to: the caller's batch run, or one of its own. Stays '' on a soft refusal. */
@@ -539,6 +588,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
539
588
  if (trigger === 'manual' && looksLikeContinuation(record.prompt)) {
540
589
  return { ok: false, report: null, profile, code: 'continuation', detail: '' }
541
590
  }
591
+ // A report already exists: a second paid look at the same turn only on request.
592
+ if (trigger === 'manual' && !force && store.report(sessionId, turn) !== null) {
593
+ return { ok: false, report: null, profile, code: 'already-analyzed', detail: '' }
594
+ }
542
595
  const previous = previousDigest !== null && typeof previousDigest === 'object' ? previousDigest : previousFinishedOf(turns, turn)
543
596
  const userText = buildAnalysisUserText(record, { followUp, previous })
544
597
  if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
@@ -678,6 +731,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
678
731
  if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
679
732
  let frozen = steeringFrozen.get(session)
680
733
  if (frozen === undefined) {
734
+ // A workspace seen for the first time may revive a paused trial.
735
+ const profile = safeProfile()
736
+ if (startNextTrial(profile)) capAndSaveProfile(profile)
681
737
  frozen = steeringNow(cwdOf(session))
682
738
  steeringFrozen.set(session, frozen)
683
739
  if (typeof session.id === 'string' && session.id.length > 0) {
@@ -689,64 +745,48 @@ export function createCoachService(ctx, store, effectiveConfig) {
689
745
  return frozen.text
690
746
  }
691
747
 
692
- /**
693
- * Replace the distilled directives with the model's new complete set; user
694
- * entries are untouched, and a re-emitted directive keeps the enabled flag
695
- * the user gave its identical text. Capped at MAX_DIRECTIVES overall.
696
- */
697
- const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
698
- const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
699
-
700
- /** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
701
- const baselineRateFor = (cwd) => {
702
- const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
748
+ /** Baselines for a new trial: the workspace's own turns when there are enough, else everything. */
749
+ const baselinesFor = (scope) => {
750
+ const scoped = scope !== '' ? allFinishedTurns({ cwd: scope }) : []
703
751
  const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
704
- return computeTrend(turns, { window: 20 }).recent.messyRate
752
+ const { messyRate, correctionRate } = computeTrend(turns, { window: 20 }).recent
753
+ return { baselineMessyRate: messyRate, baselineCorrectionRate: correctionRate }
705
754
  }
706
755
 
707
756
  /**
708
- * Merge the model's new complete set of directives ({ text, workspace? }):
709
- * user entries are untouched; the global distilled set and the distilled set
710
- * of every workspace the model mentioned are replaced; distilled entries of
711
- * other workspaces are kept (their evidence was not in this batch). A
712
- * re-emitted directive keeps its identity, state and enabled flag.
757
+ * One trial per scope at a time, and only in a workspace some session is
758
+ * loaded in: a candidate whose workspace is gone goes back to the queue and
759
+ * frees the slot, then the oldest enabled queued directive of every free
760
+ * seen scope starts its trial now, with baselines measured at this moment.
761
+ * Idempotent; returns whether any status changed.
713
762
  */
714
- const mergeDirectives = (profile, items) => {
715
- const users = profile.directives.filter((entry) => entry.source === 'user')
716
- const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
717
- const prior = profile.directives.filter((entry) => entry.source !== 'user')
718
- const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
719
- const mentioned = new Set([''])
720
- for (const item of items) mentioned.add(scopeOf(item))
721
- const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
722
- const distilled = []
723
- const seen = new Set()
724
- const baselines = new Map()
725
- for (const item of items) {
726
- const scope = scopeOf(item)
727
- const key = directiveKey(scope, item.text)
728
- if (seen.has(key) || userKeys.has(key)) continue
729
- seen.add(key)
730
- const kept = previous.get(key)
731
- if (kept !== undefined) {
732
- distilled.push({ ...kept, text: item.text })
733
- continue
734
- }
735
- // A new distilled directive goes on trial against the current messy-turn rate.
736
- if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
737
- distilled.push({
738
- id: nextDirectiveId(),
739
- text: item.text,
740
- enabled: true,
741
- source: 'distilled',
742
- createdAt: Date.now(),
743
- status: 'candidate',
744
- trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
745
- ...(scope === '' ? {} : { workspace: scope }),
746
- })
763
+ const startNextTrial = (profile) => {
764
+ const cwds = liveCwds()
765
+ const seen = new Set([''])
766
+ for (const entry of profile.directives) {
767
+ const scope = scopeOf(entry)
768
+ if (scope !== '' && cwds.some((cwd) => workspaceContains(scope, cwd))) seen.add(scope)
747
769
  }
748
- profile.directives = capDirectives([...users, ...distilled, ...untouched])
749
- return profile
770
+ let changed = false
771
+ for (const entry of profile.directives) {
772
+ if (entry.status !== 'candidate' || seen.has(scopeOf(entry))) continue
773
+ entry.status = 'queued'
774
+ delete entry.trial
775
+ changed = true
776
+ console.info('[tacit] trial paused, workspace not loaded: ' + entry.text)
777
+ }
778
+ const review = effectiveConfig().reviewCandidates === true
779
+ const busy = new Set(profile.directives.filter((entry) => entry.status === 'candidate').map(scopeOf))
780
+ for (const entry of profile.directives) {
781
+ if (entry.status !== 'queued' || entry.enabled === false || busy.has(scopeOf(entry)) || !seen.has(scopeOf(entry))) continue
782
+ if (review && entry.source !== 'user' && !(entry.approvedAt > 0)) continue
783
+ busy.add(scopeOf(entry))
784
+ entry.status = 'candidate'
785
+ entry.trial = { turns: 0, messy: 0, corrected: 0, ...baselinesFor(scopeOf(entry)), startedAt: Date.now() }
786
+ changed = true
787
+ console.info('[tacit] directive on trial: ' + entry.text)
788
+ }
789
+ return changed
750
790
  }
751
791
 
752
792
  /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
@@ -761,19 +801,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
761
801
  ? usage.beginRun({ type: 'directive-distillation', trigger: 'auto', sessionId, model: config.model, provider })
762
802
  : runId
763
803
  try {
764
- const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
804
+ const recent = store.listAllReports(20)
805
+ .map((entry) => ({ ...store.report(entry.sessionId, entry.turn), sessionId: entry.sessionId, turn: entry.turn }))
806
+ .filter((report) => report.ok !== undefined || typeof report.problems === 'object')
807
+ // Newest first, before the prompt builder reverses the list for the model.
808
+ const evidence = recent.slice(0, MAX_DIRECTIVE_EVIDENCE).map((report) => ({ sessionId: report.sessionId, turn: report.turn, trigger: typeof report.trigger === 'string' ? report.trigger : 'manual' }))
765
809
  // The model sees workspace names only; map them back to the directories they stand for.
766
- const workspaces = new Map()
767
- for (const report of recent) {
768
- if (typeof report.cwd !== 'string' || report.cwd.length === 0) continue
769
- const label = workspaceLabel(report.cwd)
770
- if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
771
- }
810
+ const labels = directiveLabels(profile, recent)
811
+ const workspaces = new Map([...labels].map(([cwd, label]) => [label, cwd]))
772
812
  const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
773
813
  provider,
774
814
  model: config.model,
775
815
  system: DIRECTIVE_SYSTEM_PROMPT,
776
- userText: buildDirectiveUserText(profile, recent.reverse()),
816
+ userText: buildDirectiveUserText(profile, recent.reverse(), { labels }),
777
817
  maxTokens: DIRECTIVE_MAX_TOKENS,
778
818
  timeoutMs: DIRECTIVE_TIMEOUT_MS,
779
819
  tool: DIRECTIVE_TOOL,
@@ -785,10 +825,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
785
825
  console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
786
826
  return
787
827
  }
788
- const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
789
- ? { text: item.text, workspace: workspaces.get(item.workspace) }
790
- : { text: item.text }))
791
- profile = mergeDirectives(safeProfile(), items)
828
+ const items = []
829
+ for (const item of kept) {
830
+ if (item.workspace !== undefined && !workspaces.has(item.workspace)) {
831
+ console.info('[tacit] dropped directive (its workspace "' + item.workspace + '" is not one the evidence came from):', item.text)
832
+ continue
833
+ }
834
+ items.push({
835
+ text: item.text,
836
+ ...(item.id === undefined ? {} : { id: item.id }),
837
+ ...(item.workspace === undefined ? {} : { workspace: workspaces.get(item.workspace) }),
838
+ })
839
+ }
840
+ if (items.length === 0) return
841
+ const before = new Map(safeProfile().directives.map((entry) => [entry.id, entry.text]))
842
+ profile = mergeDirectives(safeProfile(), items, { nextId: nextDirectiveId })
843
+ const now = Date.now()
844
+ for (const entry of profile.directives) {
845
+ if (entry.source === 'user' || isDeadDirective(entry)) continue
846
+ const previous = before.get(entry.id)
847
+ if (previous === entry.text) continue
848
+ if (previous !== undefined) entry.version = (entry.version ?? 1) + 1
849
+ entry.updatedAt = now
850
+ entry.evidence = evidence
851
+ entry.distillationRunId = usageRunId
852
+ }
853
+ startNextTrial(profile)
792
854
  profile.analysesSinceDirectives = 0
793
855
  capAndSaveProfile(profile)
794
856
  const scoped = items.filter((item) => item.workspace !== undefined).length
@@ -858,7 +920,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
858
920
  if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
859
921
  if (cwd !== undefined && cwdOf(session) !== cwd) continue
860
922
  const { turns } = turnsOf(svc, session.id)
861
- for (const turn of turns) if (turn?.finished === true) out.push(turn)
923
+ for (const turn of markCorrections(turns)) if (turn.finished === true) out.push(turn)
862
924
  }
863
925
  return out
864
926
  }
@@ -868,6 +930,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
868
930
  * deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
869
931
  const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
870
932
 
933
+ /** Back to "no bootstrap has run": every field, so a no-op never shows the previous batch's figures. */
934
+ const resetBootstrapState = () => {
935
+ bootstrapState.running = false
936
+ bootstrapState.done = 0
937
+ bootstrapState.total = 0
938
+ bootstrapState.startedAt = 0
939
+ bootstrapState.runId = ''
940
+ bootstrapState.billedCalls = 0
941
+ bootstrapState.unpricedCalls = 0
942
+ bootstrapState.usdKnown = 0
943
+ bootstrapState.tokensTotal = 0
944
+ }
945
+
871
946
  /** Mirror the bootstrap run's live counters into the state the panel polls. */
872
947
  const refreshBootstrapUsage = () => {
873
948
  const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
@@ -967,6 +1042,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
967
1042
  // Nothing to analyze is a no-op, not a run: an empty run would be written
968
1043
  // to the ledger as `failed` (no attempts) and read as a broken bootstrap.
969
1044
  if (eligible.length === 0) {
1045
+ resetBootstrapState()
970
1046
  return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
971
1047
  }
972
1048
  const config = effectiveConfig()
@@ -980,15 +1056,11 @@ export function createCoachService(ctx, store, effectiveConfig) {
980
1056
  model: config.model,
981
1057
  provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
982
1058
  })
1059
+ resetBootstrapState()
983
1060
  bootstrapState.running = true
984
- bootstrapState.done = 0
985
1061
  bootstrapState.total = eligible.length
986
1062
  bootstrapState.startedAt = Date.now()
987
1063
  bootstrapState.runId = runId
988
- bootstrapState.billedCalls = 0
989
- bootstrapState.unpricedCalls = 0
990
- bootstrapState.usdKnown = 0
991
- bootstrapState.tokensTotal = 0
992
1064
  let analyzed = 0
993
1065
  try {
994
1066
  // A small worker pool: each worker pulls the next eligible turn until the
@@ -1019,6 +1091,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
1019
1091
  bootstrapState.running = false
1020
1092
  closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
1021
1093
  refreshBootstrapUsage()
1094
+ // Mirror the final counters first, then drop the id: the tile keeps the
1095
+ // finished run's figures, but nothing may still call it the live run.
1096
+ bootstrapState.runId = ''
1022
1097
  }
1023
1098
  return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
1024
1099
  }
@@ -1031,7 +1106,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1031
1106
  * an auto trigger or another batch — reports `busy` and costs nothing. A
1032
1107
  * bootstrap running elsewhere does NOT block the batch.
1033
1108
  */
1034
- const analyzeBatch = async ({ sessionId, turns }) => {
1109
+ const analyzeBatch = async ({ sessionId, turns, force = false }) => {
1035
1110
  const { session } = turnsOf(serviceOf(ctx), sessionId)
1036
1111
  if (session === undefined) return { ok: false, results: [], profile: safeProfile(), run: null, code: 'no-session', detail: '' }
1037
1112
  const wanted = [...new Set(turns)].sort((a, b) => a - b)
@@ -1055,7 +1130,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1055
1130
  const at = next
1056
1131
  next += 1
1057
1132
  const turn = wanted[at]
1058
- const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId })
1133
+ const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId, force })
1059
1134
  const ok = result !== null && typeof result === 'object' && result.ok === true
1060
1135
  if (ok) analyzed += 1
1061
1136
  results[at] = { turn, ok, code: result?.code ?? 'call-failed', report: ok ? result.report : null }
@@ -1068,8 +1143,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
1068
1143
  // made and nothing failed, so this is a real request that succeeded —
1069
1144
  // not the zero-attempt `failed` run the default derivation would write.
1070
1145
  const entries = results.filter((entry) => entry !== null && entry !== undefined)
1071
- const allBusy = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy')
1072
- closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allBusy ? 'success' : undefined)
1146
+ const allSkipped = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy' || entry.code === 'already-analyzed')
1147
+ closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allSkipped ? 'success' : undefined)
1073
1148
  }
1074
1149
  return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
1075
1150
  }
@@ -1201,7 +1276,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1201
1276
  if (!parsed.success) {
1202
1277
  return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '', run: null }
1203
1278
  }
1204
- return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
1279
+ return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual', force: parsed.data.force === true })
1205
1280
  },
1206
1281
 
1207
1282
  /** Analyze a hand-picked set of turns of one session under a single run. */
@@ -1377,19 +1452,53 @@ export function createCoachService(ctx, store, effectiveConfig) {
1377
1452
  const found = profile.directives.find((entry) => entry.id === input.id)
1378
1453
  if (found === undefined) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
1379
1454
  found.enabled = input.enabled
1455
+ found.updatedAt = Date.now()
1380
1456
  // Re-enabling a retired directive is an explicit override.
1381
1457
  if (input.enabled && found.status === 'retired') {
1382
1458
  found.status = 'active'
1383
1459
  delete found.retiredReason
1384
1460
  }
1461
+ // A candidate is always enabled: switched off, it leaves the trial slot and queues again.
1462
+ if (!input.enabled && found.status === 'candidate') {
1463
+ found.status = 'queued'
1464
+ delete found.trial
1465
+ }
1466
+ if (!input.enabled) invalidateSteering()
1467
+ } else if (input.action === 'start-trial') {
1468
+ const found = profile.directives.find((entry) => entry.id === input.id)
1469
+ if (found === undefined || found.status !== 'queued') return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
1470
+ found.approvedAt = Date.now()
1471
+ found.updatedAt = found.approvedAt
1385
1472
  } else if (input.action === 'add') {
1386
1473
  const text = clipSafe(input.text.trim(), 220)
1387
1474
  if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
1388
- const workspace = typeof input.workspace === 'string' && input.workspace.trim().length > 0 ? input.workspace.trim() : undefined
1389
- profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === undefined ? {} : { workspace }) })
1475
+ if (POLICY_RE.test(text)) return { ok: false, profile, steering: steeringStatus(), code: 'directive-policy', detail: '' }
1476
+ const workspace = normalizeWorkspace(typeof input.workspace === 'string' ? input.workspace.trim() : '')
1477
+ profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === '' ? {} : { workspace }) })
1478
+ } else if (input.action === 'rescope') {
1479
+ const found = profile.directives.find((entry) => entry.id === input.id)
1480
+ if (found === undefined) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
1481
+ const workspace = normalizeWorkspace(input.workspace.trim())
1482
+ if (workspace === '') delete found.workspace
1483
+ else found.workspace = workspace
1484
+ // A trial measured in one workspace says nothing about another.
1485
+ if (found.status === 'candidate') {
1486
+ found.status = 'queued'
1487
+ delete found.trial
1488
+ }
1390
1489
  } else {
1391
- profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
1490
+ // A tombstone, not a delete: the distiller is told about it so it never
1491
+ // proposes the same directive (or a rewording) back.
1492
+ const found = profile.directives.find((entry) => entry.id === input.id)
1493
+ if (found !== undefined) {
1494
+ found.status = 'removed'
1495
+ found.enabled = false
1496
+ found.updatedAt = Date.now()
1497
+ delete found.trial
1498
+ invalidateSteering()
1499
+ }
1392
1500
  }
1501
+ startNextTrial(profile)
1393
1502
  const saved = capAndSaveProfile(profile)
1394
1503
  return { ok: true, profile: saved, steering: steeringStatus(), code: '', detail: '' }
1395
1504
  },
@@ -1451,15 +1560,54 @@ export function createCoachService(ctx, store, effectiveConfig) {
1451
1560
  async usageReport(args) {
1452
1561
  const parsed = usageArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1453
1562
  if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
1454
- return usage.report({
1455
- config: effectiveConfig(),
1456
- pricingStatus: pricing.status(),
1457
- pricingRates: pricing.rates(),
1458
- filters: parsed.data,
1459
- })
1563
+ return {
1564
+ ...usage.report({
1565
+ config: effectiveConfig(),
1566
+ pricingStatus: pricing.status(),
1567
+ pricingRates: pricing.rates(),
1568
+ filters: parsed.data,
1569
+ }),
1570
+ auto: autoStatus(),
1571
+ }
1460
1572
  },
1461
1573
 
1462
1574
  /** One run with its attempt rows (a live run included); expired ids are a soft `unknown-run`. */
1575
+ /** One directive's provenance and cost: ids, counts and money, never prompt text. */
1576
+ async directiveReceipt(args) {
1577
+ const parsed = directiveReceiptArgSchema.safeParse(args)
1578
+ if (!parsed.success) return { ok: false, receipt: null, code: 'bad-request', detail: '' }
1579
+ const entry = safeProfile().directives.find((candidate) => candidate.id === parsed.data.id)
1580
+ if (entry === undefined) return { ok: false, receipt: null, code: 'unknown-directive', detail: '' }
1581
+ const triggers = {}
1582
+ const conversations = new Set()
1583
+ for (const item of entry.evidence) {
1584
+ triggers[item.trigger] = (triggers[item.trigger] ?? 0) + 1
1585
+ conversations.add(item.sessionId)
1586
+ }
1587
+ const run = entry.distillationRunId === '' ? null : usage.run(entry.distillationRunId)
1588
+ const attempts = run !== null && Array.isArray(run.attempts) ? run.attempts.filter((attempt) => attempt.op === 'directive-distillation') : []
1589
+ const priced = attempts.filter((attempt) => attempt.priced !== null && typeof attempt.priced === 'object' && typeof attempt.priced.usd === 'number')
1590
+ const receipt = {
1591
+ id: entry.id,
1592
+ text: entry.text,
1593
+ scope: scopeOf(entry),
1594
+ status: entry.status,
1595
+ source: entry.source,
1596
+ enabled: entry.enabled !== false,
1597
+ createdAt: entry.createdAt,
1598
+ updatedAt: entry.updatedAt,
1599
+ evaluatedAt: entry.evaluatedAt,
1600
+ approvedAt: entry.approvedAt,
1601
+ version: entry.version,
1602
+ trial: entry.trial ?? null,
1603
+ retiredReason: entry.retiredReason ?? '',
1604
+ triggers,
1605
+ evidence: { turns: entry.evidence.length, conversations: conversations.size, items: entry.evidence },
1606
+ cost: { runId: entry.distillationRunId, calls: attempts.length, usd: priced.length === 0 ? null : priced.reduce((sum, attempt) => sum + attempt.priced.usd, 0) },
1607
+ }
1608
+ return { ok: true, receipt, code: '', detail: '' }
1609
+ },
1610
+
1463
1611
  async usageRun(args) {
1464
1612
  const parsed = usageRunArgSchema.safeParse(args)
1465
1613
  if (!parsed.success) return { ok: false, run: null, code: 'bad-request', detail: '' }