autark-cli 0.5.7 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/autark.mjs +309 -124
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI for [autark](https://autark.sh) — hypothesis-driven product runbooks.
4
4
 
5
- A product has hypotheses. Each hypothesis has runs. Each run logs actions (emails sent, posts made, replies received). The dashboard shows it all.
5
+ A product has hypotheses. Each hypothesis frames leads deduped people with a concrete angle, sourced by runs. The dashboard shows it all.
6
6
 
7
7
  ## Install
8
8
 
@@ -29,7 +29,7 @@ autark context chrome-relay
29
29
 
30
30
  autark hypothesis create --product-id <product_id> --md @./H01.md --code H01 --title "..."
31
31
  autark run start --hypothesis-id <hypothesis_id>
32
- autark log action --run-id <run_id> --channel github --title "..." --url https://github.com/...
32
+ autark lead add --hypothesis-id <hypothesis_id> --run-id <run_id> --input @/tmp/lead.json
33
33
  autark run finish --run-id <run_id> --narrative @./narrative.md
34
34
  ```
35
35
 
package/autark.mjs CHANGED
@@ -24,8 +24,25 @@ const CREDS_PATH = process.env.AUTARK_CREDENTIALS || path.join(AUTARK_HOME, 'cre
24
24
  const RAW_RUNTIME_BASE = process.env.AUTARK_RUNTIME_RAW_BASE
25
25
  || 'https://autark.sh/runtime'
26
26
  const SKILL_NAMES = ['autark', 'plumcake', 'chrome-relay', 'email', 'outreach', 'email-finder']
27
+ const SKILL_AGENTS = ['claude-code', 'codex', 'pi', 'opencode']
27
28
  const ECOSYSTEM_CLIS = ['autark-cli', 'plumcake-cli', 'chrome-relay']
28
- const PROGRAM_FILES = ['start.md', 'double-down.md', 'check.md', 'followup.md']
29
+ const PROGRAM_FILES = ['start.md', 'find_leads.md', 'double-down.md', 'check.md', 'followup.md']
30
+ const LEAD_TEMPLATE = {
31
+ person: {
32
+ full_name: 'Itay Rosen',
33
+ primary_email: 'itay@example.com',
34
+ email_status: 'guessed',
35
+ handles: {
36
+ github: 'itayrosen',
37
+ twitter: 'itayrosen',
38
+ },
39
+ headline: 'Maintainer of parallel-browser-mcp',
40
+ source: 'github',
41
+ },
42
+ lead: {
43
+ angle: 'Maintains a browser automation MCP project, so he is likely to care about real-browser access for agents.',
44
+ },
45
+ }
29
46
 
30
47
  // ============================================================ address reject
31
48
  // Two address shapes are bad enough to refuse at the CLI boundary, regardless
@@ -208,20 +225,25 @@ async function main() {
208
225
  if (command === 'unpause') return hypothesisPause(rest, 'active')
209
226
  if (!command || command === '--help' || command === '-h') return hypothesisUsage()
210
227
  }
228
+ if (group === 'lead') {
229
+ if (command === 'add') return leadAdd(rest)
230
+ if (command === 'list') return leadList(rest)
231
+ if (command === 'show') return leadShow(rest)
232
+ if (command === 'status') return leadStatus(rest)
233
+ if (command === 'template') return leadTemplate()
234
+ if (!command || command === 'help' || command === '--help' || command === '-h') return leadUsage()
235
+ }
236
+ if (group === 'touch') {
237
+ if (command === 'add') return touchAdd(rest)
238
+ if (command === 'mute') return touchMute(rest, true)
239
+ if (command === 'unmute') return touchMute(rest, false)
240
+ if (!command || command === '--help' || command === '-h') return touchUsage()
241
+ }
211
242
  if (group === 'run') {
212
243
  if (command === 'start') return runStart(rest)
213
244
  if (command === 'finish') return runFinish(rest)
214
245
  if (!command || command === '--help' || command === '-h') return runUsage()
215
246
  }
216
- if (group === 'log') {
217
- if (command === 'action') return logAction(rest)
218
- if (!command || command === '--help' || command === '-h') return logUsage()
219
- }
220
- if (group === 'action') {
221
- if (command === 'escalate') return actionEscalate(rest)
222
- if (command === 'unescalate') return actionEscalate(rest, { clear: true })
223
- if (!command || command === '--help' || command === '-h') return actionUsage()
224
- }
225
247
  if (group === 'feedback') {
226
248
  if (command === 'record') return feedbackRecord(rest)
227
249
  if (command === 'delete') return feedbackDelete(rest)
@@ -519,7 +541,13 @@ async function refreshRuntimeFiles() {
519
541
  async function refreshSkills() {
520
542
  // skills CLI switched from `-s name,name` to positional `name name`.
521
543
  // The old comma-separated form silently matches nothing.
522
- const res = await spawnSync('pnpm', ['dlx', '--silent', 'skills', 'add', 'kiluazen/kstack', ...SKILL_NAMES, '-y', '-g'])
544
+ // Target only the agents we actually run without -a the skills CLI
545
+ // sprays all ~71 known hosts and reports noisy failures for hosts that
546
+ // reject global installs (e.g. PromptScript).
547
+ // Like the skill names, agents must be repeated flags — `-a a,b` is read
548
+ // as one (invalid) agent name.
549
+ const agentFlags = SKILL_AGENTS.flatMap((a) => ['-a', a])
550
+ const res = await spawnSync('pnpm', ['dlx', '--silent', 'skills', 'add', 'kiluazen/kstack', ...SKILL_NAMES, ...agentFlags, '-y', '-g'])
523
551
  if (res.status !== 0) throw new Error(`kstack skills install failed`)
524
552
  }
525
553
 
@@ -655,6 +683,175 @@ async function hypothesisStatus(rest) {
655
683
  console.log(`${label} → ${result.status}`)
656
684
  }
657
685
 
686
+ // =============================================================== v2 leads
687
+
688
+ async function leadAdd(rest) {
689
+ const opts = parseArgs(rest)
690
+ const payload = parseJsonValue(readValue(required(opts.input, '--input @lead.json')), '--input')
691
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('--input must be a JSON object')
692
+ if (!payload.lead || typeof payload.lead !== 'object' || !String(payload.lead.angle || '').trim()) {
693
+ throw new Error('lead.angle is required in --input JSON')
694
+ }
695
+
696
+ let hypId = opts['hypothesis-id'] || opts.hypothesis_id
697
+ if (!hypId) {
698
+ const ref = opts.hypothesis || opts._[0]
699
+ if (ref) {
700
+ const [productSlug, code] = splitHypothesisRef(ref)
701
+ const ctx = await api('GET', `/v1/context/${encodeURIComponent(productSlug)}/${encodeURIComponent(code)}`)
702
+ hypId = ctx.hypothesis?.id
703
+ }
704
+ }
705
+ if (!hypId) throw new Error('pass --hypothesis-id <id> or --hypothesis <slug/Hxx>')
706
+
707
+ const body = {
708
+ ...payload,
709
+ source_run_id: opts['run-id'] || opts.run_id || opts.run || payload.source_run_id || undefined,
710
+ }
711
+ const result = await api('POST', `/v1/hypotheses/${encodeURIComponent(hypId)}/leads`, body)
712
+ console.log(JSON.stringify(result))
713
+ }
714
+
715
+ async function leadStatus(rest) {
716
+ const opts = parseArgs(rest)
717
+ const id = required(opts['lead-id'] || opts.lead_id || opts._[0], 'lead id')
718
+ const status = required(opts.status, '--status (sourced|ready|contacted|replied|done|dead)')
719
+ const result = await api('PATCH', `/v1/leads/${encodeURIComponent(id)}/status`, { status })
720
+ console.log(JSON.stringify(result))
721
+ }
722
+
723
+ // The agent's front door: one compact line per lead, id first, filtered by
724
+ // status. "Who replied?" = `autark lead list <slug> --status replied`.
725
+ async function leadList(rest) {
726
+ const opts = parseArgs(rest)
727
+ const product = required(opts.product || opts._[0], 'product slug')
728
+ const status = opts.status || ''
729
+ const qs = `?product=${encodeURIComponent(product)}${status ? `&status=${encodeURIComponent(status)}` : ''}`
730
+ const data = await api('GET', `/v1/leads${qs}`)
731
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return }
732
+ if (!data.leads.length) { console.log(`no leads${status ? ` with status ${status}` : ''} in ${product}`); return }
733
+ for (const l of data.leads) {
734
+ const who = [l.full_name, l.email && `<${l.email}>`].filter(Boolean).join(' ')
735
+ console.log(`${l.lead_id} ${l.status}${l.hypothesis ? ` ${l.hypothesis}` : ''} ${who}`)
736
+ if (l.replied_at) console.log(` replied_at=${l.replied_at}${l.last_in_at ? ` last_in=${l.last_in_at}` : ''}`)
737
+ if (l.thread_ref) console.log(` thread=${l.thread_ref} inbox=${l.inbox_ref || ''}`)
738
+ if (l.angle) console.log(` angle: ${l.angle.length > 140 ? l.angle.slice(0, 140) + '…' : l.angle}`)
739
+ }
740
+ console.error(`${data.count} lead(s). Next: autark lead show <id> · autark mail thread <thread> · autark mail reply --lead-id <id>`)
741
+ }
742
+
743
+ async function leadShow(rest) {
744
+ const opts = parseArgs(rest)
745
+ const id = required(opts['lead-id'] || opts.lead_id || opts._[0], 'lead id')
746
+ const lead = await api('GET', `/v1/leads/${encodeURIComponent(id)}`)
747
+ console.log(JSON.stringify(lead, null, 2))
748
+ }
749
+
750
+ // muted=true marks an inbound touch as a non-reply (ticket bot, autoresponder).
751
+ // The worker recomputes the lead verdict in the same write and the reply sweep
752
+ // skips muted touches, so the judgment sticks. --action-id targets a v1 action
753
+ // row instead (legacy bridge; the replies tab filters those at render).
754
+ async function touchMute(rest, muted) {
755
+ const opts = parseArgs(rest)
756
+ const actionId = opts['action-id'] || opts.action_id
757
+ if (actionId) {
758
+ const result = await api('PATCH', `/v1/actions/${encodeURIComponent(actionId)}/mute`, { muted })
759
+ console.log(JSON.stringify(result))
760
+ return
761
+ }
762
+ const id = required(opts['touch-id'] || opts.touch_id || opts._[0], 'touch id (or --action-id for v1 rows)')
763
+ const result = await api('PATCH', `/v1/touches/${encodeURIComponent(id)}/mute`, { muted })
764
+ console.log(JSON.stringify(result))
765
+ }
766
+
767
+ async function touchAdd(rest) {
768
+ const opts = parseArgs(rest)
769
+ const leadId = required(opts['lead-id'] || opts.lead_id, '--lead-id')
770
+ const channel = required(opts.channel, '--channel')
771
+ const result = await api('POST', `/v1/leads/${encodeURIComponent(leadId)}/touches`, {
772
+ channel,
773
+ direction: opts.direction === 'in' ? 'in' : 'out',
774
+ thread_ref: opts['thread-ref'] || opts.thread_ref || undefined,
775
+ inbox_ref: opts['inbox-ref'] || opts.inbox_ref || undefined,
776
+ summary: opts.summary || undefined,
777
+ occurred_at: opts['occurred-at'] || opts.occurred_at || undefined,
778
+ status: opts.status || undefined,
779
+ })
780
+ console.log(JSON.stringify(result))
781
+ }
782
+
783
+ function touchUsage() {
784
+ console.log(`autark touch
785
+
786
+ add --lead-id <id> --channel <c>
787
+ [--direction out|in] # default out
788
+ [--thread-ref <ref>] # AgentMail thread id or public URL of YOUR comment
789
+ [--summary "<one line>"]
790
+ [--occurred-at <iso>]
791
+ [--status <s>] # override the automatic advance
792
+
793
+ One row per outreach interaction, recorded against an explicit lead id —
794
+ the CLI never guesses which lead an interaction belongs to. Writing the
795
+ touch advances lead.status in the same transaction (out: ready→contacted,
796
+ in: →replied) unless --status overrides it.
797
+
798
+ Email is a special case: \`autark mail send --lead-id <id>\` records the
799
+ touch automatically — do not call \`touch add\` for the same send.
800
+
801
+ mute <touch-id> mark an inbound touch as a non-reply (bot,
802
+ autoresponder); lead verdict recomputes and
803
+ the reply sweep respects the judgment
804
+ mute --action-id <id> same, for a legacy v1 action row
805
+ unmute <touch-id> reverse it`)
806
+ }
807
+
808
+ function leadTemplate() {
809
+ printJson(LEAD_TEMPLATE)
810
+ }
811
+
812
+ function leadUsage() {
813
+ console.log(`autark lead
814
+
815
+ template
816
+ print a minimal JSON payload for lead add
817
+
818
+ add --hypothesis-id <id> --input @/tmp/lead.json [--run-id <id>]
819
+ add --hypothesis <slug>/<H01> --input @/tmp/lead.json [--run-id <id>]
820
+
821
+ list <slug> [--status replied|contacted|ready|...] [--json]
822
+ the front door: one line per lead, ids first, newest replies on top.
823
+ "who replied?" = lead list <slug> --status replied
824
+
825
+ show <lead-id>
826
+ person + bet + status + the ordered touch log with ids — run this
827
+ when handed a lead link, before replying or muting
828
+
829
+ status <lead-id> --status sourced|ready|contacted|replied|done|dead
830
+ explicit status write — normally NOT needed: sending via
831
+ \`autark mail send --lead-id\` or \`autark touch add\` advances
832
+ status automatically in the same transaction
833
+
834
+ lead add records one sourced lead: person identity + the angle connecting that
835
+ person to the hypothesis. It sends one ID-first request to the worker and
836
+ prints exactly one compact JSON object to stdout:
837
+
838
+ {"person_id":"...","lead_id":"...","person_existed":false,"lead_existed":false}
839
+
840
+ Minimum payload:
841
+
842
+ ${JSON.stringify(LEAD_TEMPLATE, null, 2)}
843
+
844
+ Required:
845
+ - lead.angle
846
+ - enough person identity to dedupe: primary_email, a supported handle, or full_name
847
+
848
+ Optional person fields:
849
+ email_status, headline, source, bio, signals
850
+
851
+ find_leads should write /tmp/lead-N.json and call lead add with --input @file.
852
+ No outreach is sent and no touch rows are written.`)
853
+ }
854
+
658
855
  // =============================================================== runs
659
856
 
660
857
  async function runStart(rest) {
@@ -679,59 +876,6 @@ async function runFinish(rest) {
679
876
  console.log(`${result.id} finished_at=${result.finished_at}`)
680
877
  }
681
878
 
682
- // ============================================================ actions
683
-
684
- async function logAction(rest) {
685
- const opts = parseArgs(rest)
686
- const run = required(opts['run-id'] || opts.run_id || opts.run, '--run-id')
687
- const channel = required(opts.channel, '--channel')
688
- const title = required(opts.title, '--title')
689
- const needsHuman = Boolean(opts['needs-human'] || opts.needs_human || opts.escalate)
690
- const escalationReason = opts['escalation-reason'] || opts.escalation_reason || opts.reason || undefined
691
-
692
- // Merge `our_comment_id` into metadata if passed as a sugar flag. This is
693
- // the per-channel anchor the reply-state cron uses to detect engagement on
694
- // OUR specific comment (vs the parent we were replying to). Always pass
695
- // it when you posted a comment somewhere we can re-fetch — github issue/PR
696
- // comments, gists, HN replies, Substack comments, Reddit comments.
697
- const meta = opts.metadata ? JSON.parse(readValue(opts.metadata)) : undefined
698
- const ourCommentId = opts['our-comment-id'] || opts.our_comment_id
699
- const metadata = ourCommentId
700
- ? { ...(meta || {}), our_comment_id: String(ourCommentId) }
701
- : meta
702
-
703
- const result = await api('POST', `/v1/runs/${encodeURIComponent(run)}/actions`, {
704
- channel,
705
- title,
706
- url: opts.url || undefined,
707
- agentmail_thread_id: opts['agentmail-thread-id'] || opts['thread-id'] || opts.thread_id || undefined,
708
- agentmail_inbox_id: opts['agentmail-inbox-id'] || opts.inbox_id || opts.agentmail_inbox_id || undefined,
709
- recipient: opts.recipient || undefined,
710
- metadata,
711
- needs_human: needsHuman || undefined,
712
- escalation_reason: needsHuman ? escalationReason : undefined,
713
- occurred_at: opts['occurred-at'] || opts.occurred_at || undefined,
714
- })
715
- console.log(result.id)
716
- }
717
-
718
- // ============================================================ action escalate
719
-
720
- async function actionEscalate(rest, { clear = false } = {}) {
721
- const opts = parseArgs(rest)
722
- const id = required(opts['action-id'] || opts.action_id || opts._[0], 'action id')
723
- const reason = opts.reason || opts._.slice(1).join(' ') || undefined
724
- const body = { needs_human: !clear }
725
- if (!clear) body.reason = reason || ''
726
- const result = await api('PATCH', `/v1/actions/${encodeURIComponent(id)}/escalate`, body)
727
- console.log(`${result.id} needs_human=${result.needs_human}${result.escalation_reason ? ` reason="${result.escalation_reason}"` : ''}`)
728
- }
729
-
730
- function actionUsage() {
731
- console.log(`autark action escalate <action-id> [--reason "<one-line why>"]
732
- autark action unescalate <action-id>`)
733
- }
734
-
735
879
  // ============================================================ product pause
736
880
 
737
881
  async function productPause(rest, fixedStatus) {
@@ -977,7 +1121,8 @@ async function mailSend(rest) {
977
1121
  response: result,
978
1122
  })
979
1123
  noteAutoLog(action, opts)
980
- printJson({ ...result, autark_action_id: action?.id })
1124
+ const touch = await maybeLogMailTouch(opts, { kind: 'send', subject, response: result })
1125
+ printJson({ ...result, autark_action_id: action?.id, autark_touch_id: touch?.touch_id, autark_lead_status: touch?.status })
981
1126
  }
982
1127
 
983
1128
  async function mailReply(rest, mode) {
@@ -995,7 +1140,8 @@ async function mailReply(rest, mode) {
995
1140
  metadata: { message_id: messageId },
996
1141
  })
997
1142
  noteAutoLog(action, opts)
998
- printJson({ ...result, autark_action_id: action?.id })
1143
+ const touch = await maybeLogMailTouch(opts, { kind: mode, subject: opts.subject, response: result })
1144
+ printJson({ ...result, autark_action_id: action?.id, autark_touch_id: touch?.touch_id, autark_lead_status: touch?.status })
999
1145
  }
1000
1146
 
1001
1147
  async function mailForward(rest) {
@@ -1013,17 +1159,18 @@ async function mailForward(rest) {
1013
1159
  metadata: { message_id: messageId },
1014
1160
  })
1015
1161
  noteAutoLog(action, opts)
1016
- printJson({ ...result, autark_action_id: action?.id })
1162
+ const touch = await maybeLogMailTouch(opts, { kind: 'forward', subject: opts.subject, response: result })
1163
+ printJson({ ...result, autark_action_id: action?.id, autark_touch_id: touch?.touch_id, autark_lead_status: touch?.status })
1017
1164
  }
1018
1165
 
1019
- // Explicit human-readable cue so agents (and humans) see that the action
1020
- // landed in autark and don't follow up with a redundant `autark log action`
1021
- // for the same send. Stays on stderr so JSON consumers piping stdout aren't
1022
- // affected. Only fires when --run-id was passed and the log succeeded.
1166
+ // Explicit human-readable cue so agents (and humans) see that the send was
1167
+ // recorded against the run no separate logging step is needed or possible.
1168
+ // Stays on stderr so JSON consumers piping stdout aren't affected. Only
1169
+ // fires when --run-id was passed and the log succeeded.
1023
1170
  function noteAutoLog(action, opts) {
1024
1171
  if (!action?.id) return
1025
1172
  const runId = opts['run-id'] || opts.run_id || opts.run
1026
- console.error(`autark: auto-logged action ${action.id} (run ${runId})do NOT run \`autark log action\` for this`)
1173
+ console.error(`autark: send recorded as ${action.id} on run ${runId} — nothing else to log`)
1027
1174
  }
1028
1175
 
1029
1176
  async function mailThreads(rest) {
@@ -1097,6 +1244,28 @@ function mailBody(opts, base = {}) {
1097
1244
  return body
1098
1245
  }
1099
1246
 
1247
+ // v2: when --lead-id is passed, the send is recorded as a touch on that lead
1248
+ // and the lead's status advances (ready → contacted) in the same worker
1249
+ // transact. The send IS the bookkeeping — no separate status command needed.
1250
+ async function maybeLogMailTouch(opts, { kind, subject, response }) {
1251
+ const leadId = opts['lead-id'] || opts.lead_id
1252
+ if (!leadId) return null
1253
+ const threadId = response?.thread_id
1254
+ if (!threadId) throw new Error('AgentMail response missing thread_id; refusing to log email touch')
1255
+ // inbox_ref tells the reply sweep which inbox owns the thread — stamped
1256
+ // from the sending credentials so it survives later inbox rotation.
1257
+ const creds = requireAgentmailCredentials()
1258
+ const touch = await api('POST', `/v1/leads/${encodeURIComponent(leadId)}/touches`, {
1259
+ channel: 'email',
1260
+ direction: 'out',
1261
+ thread_ref: threadId,
1262
+ inbox_ref: creds.inboxId,
1263
+ summary: subject ? `${kind}: ${subject}` : kind,
1264
+ })
1265
+ console.error(`autark: touch ${touch.touch_id} on lead ${touch.lead_id} — status ${touch.status}${touch.status_changed ? ' (advanced)' : ''}`)
1266
+ return touch
1267
+ }
1268
+
1100
1269
  async function maybeLogMailAction(opts, { kind, defaultTitle, recipient, subject, response, metadata = {} }) {
1101
1270
  const runId = opts['run-id'] || opts.run_id || opts.run
1102
1271
  if (!runId) return null
@@ -1214,6 +1383,9 @@ function printProductContext(r) {
1214
1383
  kv('product.url', r.product.url)
1215
1384
  kv('product.visibility', r.product.visibility)
1216
1385
  kv('product.hypothesis_count', (r.hypotheses || []).length)
1386
+ kv('product.person_count', r.product.person_count || (r.people || []).length || 0)
1387
+ kv('product.lead_count', r.product.lead_count || (r.leads || []).length || 0)
1388
+ kv('product.ready_lead_count', r.product.ready_lead_count || (r.leads || []).filter((l) => l.status === 'ready').length || 0)
1217
1389
  // Product-level operator feedback (newest first). Append-only + timestamped,
1218
1390
  // so each entry is a distinct nudge — re-read these every run.
1219
1391
  kv('product.feedback_count', (r.feedback || []).length)
@@ -1229,6 +1401,8 @@ function printProductContext(r) {
1229
1401
  kv(`hypothesis.${h.code}.run_count`, h.run_count)
1230
1402
  kv(`hypothesis.${h.code}.action_count`, h.action_count)
1231
1403
  kv(`hypothesis.${h.code}.reply_count`, h.reply_count)
1404
+ kv(`hypothesis.${h.code}.lead_count`, h.lead_count || (h.leads || []).length || 0)
1405
+ kv(`hypothesis.${h.code}.ready_lead_count`, h.ready_lead_count || (h.leads || []).filter((l) => l.status === 'ready').length || 0)
1232
1406
  if (h.needs_human_count) kv(`hypothesis.${h.code}.needs_human_count`, h.needs_human_count)
1233
1407
  const actions = (h.runs || []).flatMap((run) => run.actions || [])
1234
1408
  const by = channelBreakdown(actions)
@@ -1237,6 +1411,16 @@ function printProductContext(r) {
1237
1411
  kv(`hypothesis.${h.code}.channel.${ch}.replied`, by[ch].replied)
1238
1412
  }
1239
1413
  }
1414
+ for (const lead of (r.leads || [])) {
1415
+ const shortL = actionShortId(lead.id)
1416
+ kv(`lead.${shortL}.id`, lead.id)
1417
+ kv(`lead.${shortL}.status`, lead.status)
1418
+ kv(`lead.${shortL}.hypothesis_code`, lead.hypothesis_code)
1419
+ kv(`lead.${shortL}.person_name`, lead.person?.full_name)
1420
+ kv(`lead.${shortL}.person_email`, lead.person?.primary_email)
1421
+ kv(`lead.${shortL}.headline`, lead.person?.headline)
1422
+ kv(`lead.${shortL}.angle`, lead.angle)
1423
+ }
1240
1424
  console.log('---')
1241
1425
  // ---- narrative ----
1242
1426
  console.log(`# ${r.product.slug} — ${r.product.name}\n`)
@@ -1261,7 +1445,18 @@ function printProductContext(r) {
1261
1445
  .map((ch) => `${ch} ${by[ch].replied}/${by[ch].sent}`)
1262
1446
  .join(' ')
1263
1447
  const tail = channelSummary ? ` — replies/sends by channel: ${channelSummary}` : ''
1264
- console.log(`- [${h.status}] ${h.code} — ${h.title} (runs: ${h.run_count}, actions: ${h.action_count || 0}, replies: ${h.reply_count || 0})${tail}`)
1448
+ console.log(`- [${h.status}] ${h.code} — ${h.title} (runs: ${h.run_count}, actions: ${h.action_count || 0}, replies: ${h.reply_count || 0}, leads: ${h.lead_count || 0})${tail}`)
1449
+ }
1450
+ }
1451
+ if (r.leads?.length) {
1452
+ console.log(`\n## Recent leads (${r.leads.length})\n`)
1453
+ for (const lead of r.leads) {
1454
+ const person = lead.person || {}
1455
+ const label = person.full_name || person.primary_email || Object.entries(person.handles || {})[0]?.join(':') || lead.person_id || 'unknown person'
1456
+ const hyp = lead.hypothesis_code ? `${lead.hypothesis_code} ` : ''
1457
+ const headline = person.headline ? ` — ${person.headline}` : ''
1458
+ console.log(`- [${lead.status}] ${hyp}${label}${headline}`)
1459
+ if (lead.angle) console.log(` angle: ${lead.angle}`)
1265
1460
  }
1266
1461
  }
1267
1462
  }
@@ -1274,6 +1469,8 @@ function printHypothesisContext(result) {
1274
1469
  kv('hypothesis.code', result.hypothesis.code)
1275
1470
  kv('hypothesis.title', result.hypothesis.title)
1276
1471
  kv('hypothesis.status', result.hypothesis.status)
1472
+ kv('hypothesis.lead_count', result.hypothesis.lead_count || (result.leads || []).length || 0)
1473
+ kv('hypothesis.ready_lead_count', result.hypothesis.ready_lead_count || (result.leads || []).filter((l) => l.status === 'ready').length || 0)
1277
1474
  // Feedback — operator nudges left on this hypothesis, oldest first. The
1278
1475
  // worker returns them; previously the CLI silently dropped them, so agents
1279
1476
  // never saw the operator's running corrections. Now surface them up top
@@ -1297,9 +1494,11 @@ function printHypothesisContext(result) {
1297
1494
  }
1298
1495
  for (const run of (result.runs || [])) {
1299
1496
  const shortRun = actionShortId(run.id)
1497
+ const runLeads = (result.leads || []).filter((l) => l.source_run_id === run.id)
1300
1498
  kv(`run.${shortRun}.id`, run.id)
1301
1499
  kv(`run.${shortRun}.run_number`, run.run_number)
1302
1500
  kv(`run.${shortRun}.started_at`, run.started_at)
1501
+ kv(`run.${shortRun}.lead_count`, runLeads.length)
1303
1502
  if (run.finished_at) kv(`run.${shortRun}.finished_at`, run.finished_at)
1304
1503
  for (const a of (run.actions || [])) {
1305
1504
  const shortA = actionShortId(a.id)
@@ -1326,6 +1525,16 @@ function printHypothesisContext(result) {
1326
1525
  }
1327
1526
  }
1328
1527
  }
1528
+ for (const lead of (result.leads || [])) {
1529
+ const shortL = actionShortId(lead.id)
1530
+ kv(`lead.${shortL}.id`, lead.id)
1531
+ kv(`lead.${shortL}.status`, lead.status)
1532
+ kv(`lead.${shortL}.source_run_id`, lead.source_run_id)
1533
+ kv(`lead.${shortL}.person_name`, lead.person?.full_name)
1534
+ kv(`lead.${shortL}.person_email`, lead.person?.primary_email)
1535
+ kv(`lead.${shortL}.headline`, lead.person?.headline)
1536
+ kv(`lead.${shortL}.angle`, lead.angle)
1537
+ }
1329
1538
  console.log('---')
1330
1539
  // ---- narrative ----
1331
1540
  console.log(`# ${result.product.slug}/${result.hypothesis.code} — ${result.hypothesis.title}\n`)
@@ -1338,9 +1547,21 @@ function printHypothesisContext(result) {
1338
1547
  console.log(`- [${when}]${tag} ${f.text}`)
1339
1548
  }
1340
1549
  }
1550
+ if (result.leads?.length) {
1551
+ console.log(`\n\n## Leads (${result.leads.length})\n`)
1552
+ for (const lead of result.leads) {
1553
+ const person = lead.person || {}
1554
+ const label = person.full_name || person.primary_email || Object.entries(person.handles || {})[0]?.join(':') || lead.person_id || 'unknown person'
1555
+ const headline = person.headline ? ` — ${person.headline}` : ''
1556
+ const runTag = lead.source_run_id ? ` (run ${actionShortId(lead.source_run_id)})` : ''
1557
+ console.log(`- [${lead.status}] ${label}${headline}${runTag}`)
1558
+ if (lead.angle) console.log(` angle: ${lead.angle}`)
1559
+ }
1560
+ }
1341
1561
  for (const run of (result.runs || [])) {
1562
+ const runLeads = (result.leads || []).filter((l) => l.source_run_id === run.id)
1342
1563
  console.log(`\n\n## Run ${run.run_number} (${run.id})`)
1343
- console.log(`started: ${run.started_at}${run.finished_at ? ` finished: ${run.finished_at}` : ' (in progress)'}`)
1564
+ console.log(`started: ${run.started_at}${run.finished_at ? ` finished: ${run.finished_at}` : ' (in progress)'}${runLeads.length ? ` leads sourced: ${runLeads.length}` : ''}`)
1344
1565
  if (run.actions?.length) {
1345
1566
  console.log(`\nActions:`)
1346
1567
  for (const a of run.actions) {
@@ -1423,6 +1644,14 @@ function readValue(value) {
1423
1644
  return String(value)
1424
1645
  }
1425
1646
 
1647
+ function parseJsonValue(value, label = 'json') {
1648
+ try {
1649
+ return JSON.parse(value)
1650
+ } catch (e) {
1651
+ throw new Error(`${label} must be valid JSON: ${e.message}`)
1652
+ }
1653
+ }
1654
+
1426
1655
  function listOpt(value, label) {
1427
1656
  if (value === undefined || value === null || value === '') {
1428
1657
  if (label) throw new Error(`${label} is required`)
@@ -1472,10 +1701,10 @@ function mailUsage() {
1472
1701
  console.log(`autark mail
1473
1702
 
1474
1703
  setup --prefix <name> [--force]
1475
- send --to <email[,email]> --subject <s> [--text @body.txt] [--html @body.html] [--cc <e>] [--bcc <e>] [--reply-to <e>] [--label <l>] [--attachment <a>] [--run-id <id>] [--dry-run]
1476
- reply --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>]
1477
- reply-all --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>]
1478
- forward --message-id <id> --to <email> [--text @body.txt] [--html @body.html] [--run-id <id>]
1704
+ send --to <email[,email]> --subject <s> [--text @body.txt] [--html @body.html] [--cc <e>] [--bcc <e>] [--reply-to <e>] [--label <l>] [--attachment <a>] [--run-id <id>] [--lead-id <id>] [--dry-run]
1705
+ reply --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>] [--lead-id <id>]
1706
+ reply-all --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>] [--lead-id <id>]
1707
+ forward --message-id <id> --to <email> [--text @body.txt] [--html @body.html] [--run-id <id>] [--lead-id <id>]
1479
1708
  threads [--limit N]
1480
1709
  thread <thread_id>
1481
1710
  messages [--limit N]
@@ -1509,9 +1738,9 @@ function usage() {
1509
1738
  product pause|unpause stop / resume cron on a product
1510
1739
  hypothesis create|status create or update hypotheses
1511
1740
  hypothesis pause|unpause stop / resume work on a hypothesis
1741
+ lead list|add|show|status the sheet: list by status / record / inspect / set
1742
+ touch add|mute|unmute record an interaction / judge a non-reply
1512
1743
  run start|finish start / finish a run
1513
- log action record one outreach touch
1514
- action escalate flag an action for human attention
1515
1744
  feedback record|delete leave a free-text nudge on a hypothesis or product
1516
1745
  context [<slug>|...] pull product or hypothesis context
1517
1746
 
@@ -1564,50 +1793,6 @@ function runUsage() {
1564
1793
  → seals the run with the narrative blob`)
1565
1794
  }
1566
1795
 
1567
- function logUsage() {
1568
- console.log(`autark log
1569
-
1570
- action --run-id <id> --channel <c> --title <t>
1571
- [--url <u>] # github / reddit / hn / blog / gist / linkedin
1572
- [--agentmail-thread-id <uuid>] # email
1573
- [--agentmail-inbox-id <email>] # email — defaults to your inbox
1574
- [--recipient <email>] # email
1575
- [--our-comment-id <id>] # see "Always capture our_comment_id" below
1576
- [--metadata @./meta.json] # any other channel-specific extras
1577
- [--escalate --reason "<why>"] # flag this action as needs_human
1578
-
1579
- One row per external touch. Body content stays in AgentMail/GitHub/etc;
1580
- this just records the pointer + the title agents see on the dashboard.
1581
-
1582
- Log the URL of YOUR comment, not the parent
1583
-
1584
- The reply-state cron parses the comment id out of the --url you log to
1585
- detect "did anyone reply to you?". Most channels' URLs already contain
1586
- the id; you just have to log the permalink of your OWN comment, not the
1587
- issue/post URL you replied to.
1588
-
1589
- github (issue or PR comment):
1590
- use the response's html_url, ends in #issuecomment-<id>
1591
- NEW_URL=$(gh api repos/<o>/<r>/issues/<n>/comments \\
1592
- -X POST -f body=@body.md --jq .html_url)
1593
-
1594
- gist:
1595
- ends in #gistcomment-<id>
1596
-
1597
- hn:
1598
- .../item?id=<your_new_item_id> (your id, not the parent's)
1599
-
1600
- reddit:
1601
- .../r/<sub>/comments/<thread>/<title>/<your_comment_id>/
1602
-
1603
- substack:
1604
- Substack permalinks don't always include the comment id, so for
1605
- this channel use --our-comment-id <id> from the response body.
1606
-
1607
- Escape hatch: --our-comment-id <id> tucks the id into metadata. Use it
1608
- when the URL pattern can't encode the comment (Substack, edge HN cases).`)
1609
- }
1610
-
1611
1796
  function contextUsage() {
1612
1797
  console.log(`autark context
1613
1798
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.5.7",
4
- "description": "CLI for autark \u2014 hypothesis-driven product runbooks. Track products, hypotheses, runs, and actions from the terminal.",
3
+ "version": "0.7.0",
4
+ "description": "CLI for autark hypothesis-driven product runbooks. Track products, hypotheses, runs, and actions from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {