autark-cli 0.5.8 → 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 +162 -122
  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,6 +24,7 @@ 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
29
  const PROGRAM_FILES = ['start.md', 'find_leads.md', 'double-down.md', 'check.md', 'followup.md']
29
30
  const LEAD_TEMPLATE = {
@@ -226,23 +227,23 @@ async function main() {
226
227
  }
227
228
  if (group === 'lead') {
228
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)
229
233
  if (command === 'template') return leadTemplate()
230
234
  if (!command || command === 'help' || command === '--help' || command === '-h') return leadUsage()
231
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
+ }
232
242
  if (group === 'run') {
233
243
  if (command === 'start') return runStart(rest)
234
244
  if (command === 'finish') return runFinish(rest)
235
245
  if (!command || command === '--help' || command === '-h') return runUsage()
236
246
  }
237
- if (group === 'log') {
238
- if (command === 'action') return logAction(rest)
239
- if (!command || command === '--help' || command === '-h') return logUsage()
240
- }
241
- if (group === 'action') {
242
- if (command === 'escalate') return actionEscalate(rest)
243
- if (command === 'unescalate') return actionEscalate(rest, { clear: true })
244
- if (!command || command === '--help' || command === '-h') return actionUsage()
245
- }
246
247
  if (group === 'feedback') {
247
248
  if (command === 'record') return feedbackRecord(rest)
248
249
  if (command === 'delete') return feedbackDelete(rest)
@@ -540,7 +541,13 @@ async function refreshRuntimeFiles() {
540
541
  async function refreshSkills() {
541
542
  // skills CLI switched from `-s name,name` to positional `name name`.
542
543
  // The old comma-separated form silently matches nothing.
543
- 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'])
544
551
  if (res.status !== 0) throw new Error(`kstack skills install failed`)
545
552
  }
546
553
 
@@ -705,6 +712,99 @@ async function leadAdd(rest) {
705
712
  console.log(JSON.stringify(result))
706
713
  }
707
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
+
708
808
  function leadTemplate() {
709
809
  printJson(LEAD_TEMPLATE)
710
810
  }
@@ -718,6 +818,19 @@ function leadUsage() {
718
818
  add --hypothesis-id <id> --input @/tmp/lead.json [--run-id <id>]
719
819
  add --hypothesis <slug>/<H01> --input @/tmp/lead.json [--run-id <id>]
720
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
+
721
834
  lead add records one sourced lead: person identity + the angle connecting that
722
835
  person to the hypothesis. It sends one ID-first request to the worker and
723
836
  prints exactly one compact JSON object to stdout:
@@ -763,59 +876,6 @@ async function runFinish(rest) {
763
876
  console.log(`${result.id} finished_at=${result.finished_at}`)
764
877
  }
765
878
 
766
- // ============================================================ actions
767
-
768
- async function logAction(rest) {
769
- const opts = parseArgs(rest)
770
- const run = required(opts['run-id'] || opts.run_id || opts.run, '--run-id')
771
- const channel = required(opts.channel, '--channel')
772
- const title = required(opts.title, '--title')
773
- const needsHuman = Boolean(opts['needs-human'] || opts.needs_human || opts.escalate)
774
- const escalationReason = opts['escalation-reason'] || opts.escalation_reason || opts.reason || undefined
775
-
776
- // Merge `our_comment_id` into metadata if passed as a sugar flag. This is
777
- // the per-channel anchor the reply-state cron uses to detect engagement on
778
- // OUR specific comment (vs the parent we were replying to). Always pass
779
- // it when you posted a comment somewhere we can re-fetch — github issue/PR
780
- // comments, gists, HN replies, Substack comments, Reddit comments.
781
- const meta = opts.metadata ? JSON.parse(readValue(opts.metadata)) : undefined
782
- const ourCommentId = opts['our-comment-id'] || opts.our_comment_id
783
- const metadata = ourCommentId
784
- ? { ...(meta || {}), our_comment_id: String(ourCommentId) }
785
- : meta
786
-
787
- const result = await api('POST', `/v1/runs/${encodeURIComponent(run)}/actions`, {
788
- channel,
789
- title,
790
- url: opts.url || undefined,
791
- agentmail_thread_id: opts['agentmail-thread-id'] || opts['thread-id'] || opts.thread_id || undefined,
792
- agentmail_inbox_id: opts['agentmail-inbox-id'] || opts.inbox_id || opts.agentmail_inbox_id || undefined,
793
- recipient: opts.recipient || undefined,
794
- metadata,
795
- needs_human: needsHuman || undefined,
796
- escalation_reason: needsHuman ? escalationReason : undefined,
797
- occurred_at: opts['occurred-at'] || opts.occurred_at || undefined,
798
- })
799
- console.log(result.id)
800
- }
801
-
802
- // ============================================================ action escalate
803
-
804
- async function actionEscalate(rest, { clear = false } = {}) {
805
- const opts = parseArgs(rest)
806
- const id = required(opts['action-id'] || opts.action_id || opts._[0], 'action id')
807
- const reason = opts.reason || opts._.slice(1).join(' ') || undefined
808
- const body = { needs_human: !clear }
809
- if (!clear) body.reason = reason || ''
810
- const result = await api('PATCH', `/v1/actions/${encodeURIComponent(id)}/escalate`, body)
811
- console.log(`${result.id} needs_human=${result.needs_human}${result.escalation_reason ? ` reason="${result.escalation_reason}"` : ''}`)
812
- }
813
-
814
- function actionUsage() {
815
- console.log(`autark action escalate <action-id> [--reason "<one-line why>"]
816
- autark action unescalate <action-id>`)
817
- }
818
-
819
879
  // ============================================================ product pause
820
880
 
821
881
  async function productPause(rest, fixedStatus) {
@@ -1061,7 +1121,8 @@ async function mailSend(rest) {
1061
1121
  response: result,
1062
1122
  })
1063
1123
  noteAutoLog(action, opts)
1064
- 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 })
1065
1126
  }
1066
1127
 
1067
1128
  async function mailReply(rest, mode) {
@@ -1079,7 +1140,8 @@ async function mailReply(rest, mode) {
1079
1140
  metadata: { message_id: messageId },
1080
1141
  })
1081
1142
  noteAutoLog(action, opts)
1082
- 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 })
1083
1145
  }
1084
1146
 
1085
1147
  async function mailForward(rest) {
@@ -1097,17 +1159,18 @@ async function mailForward(rest) {
1097
1159
  metadata: { message_id: messageId },
1098
1160
  })
1099
1161
  noteAutoLog(action, opts)
1100
- 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 })
1101
1164
  }
1102
1165
 
1103
- // Explicit human-readable cue so agents (and humans) see that the action
1104
- // landed in autark and don't follow up with a redundant `autark log action`
1105
- // for the same send. Stays on stderr so JSON consumers piping stdout aren't
1106
- // 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.
1107
1170
  function noteAutoLog(action, opts) {
1108
1171
  if (!action?.id) return
1109
1172
  const runId = opts['run-id'] || opts.run_id || opts.run
1110
- 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`)
1111
1174
  }
1112
1175
 
1113
1176
  async function mailThreads(rest) {
@@ -1181,6 +1244,28 @@ function mailBody(opts, base = {}) {
1181
1244
  return body
1182
1245
  }
1183
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
+
1184
1269
  async function maybeLogMailAction(opts, { kind, defaultTitle, recipient, subject, response, metadata = {} }) {
1185
1270
  const runId = opts['run-id'] || opts.run_id || opts.run
1186
1271
  if (!runId) return null
@@ -1616,10 +1701,10 @@ function mailUsage() {
1616
1701
  console.log(`autark mail
1617
1702
 
1618
1703
  setup --prefix <name> [--force]
1619
- 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]
1620
- reply --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>]
1621
- reply-all --message-id <id> [--text @reply.txt] [--html @reply.html] [--run-id <id>]
1622
- 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>]
1623
1708
  threads [--limit N]
1624
1709
  thread <thread_id>
1625
1710
  messages [--limit N]
@@ -1653,10 +1738,9 @@ function usage() {
1653
1738
  product pause|unpause stop / resume cron on a product
1654
1739
  hypothesis create|status create or update hypotheses
1655
1740
  hypothesis pause|unpause stop / resume work on a hypothesis
1656
- lead add|template record sourced v2 leads
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
1657
1743
  run start|finish start / finish a run
1658
- log action record one outreach touch
1659
- action escalate flag an action for human attention
1660
1744
  feedback record|delete leave a free-text nudge on a hypothesis or product
1661
1745
  context [<slug>|...] pull product or hypothesis context
1662
1746
 
@@ -1709,50 +1793,6 @@ function runUsage() {
1709
1793
  → seals the run with the narrative blob`)
1710
1794
  }
1711
1795
 
1712
- function logUsage() {
1713
- console.log(`autark log
1714
-
1715
- action --run-id <id> --channel <c> --title <t>
1716
- [--url <u>] # github / reddit / hn / blog / gist / linkedin
1717
- [--agentmail-thread-id <uuid>] # email
1718
- [--agentmail-inbox-id <email>] # email — defaults to your inbox
1719
- [--recipient <email>] # email
1720
- [--our-comment-id <id>] # see "Always capture our_comment_id" below
1721
- [--metadata @./meta.json] # any other channel-specific extras
1722
- [--escalate --reason "<why>"] # flag this action as needs_human
1723
-
1724
- One row per external touch. Body content stays in AgentMail/GitHub/etc;
1725
- this just records the pointer + the title agents see on the dashboard.
1726
-
1727
- Log the URL of YOUR comment, not the parent
1728
-
1729
- The reply-state cron parses the comment id out of the --url you log to
1730
- detect "did anyone reply to you?". Most channels' URLs already contain
1731
- the id; you just have to log the permalink of your OWN comment, not the
1732
- issue/post URL you replied to.
1733
-
1734
- github (issue or PR comment):
1735
- use the response's html_url, ends in #issuecomment-<id>
1736
- NEW_URL=$(gh api repos/<o>/<r>/issues/<n>/comments \\
1737
- -X POST -f body=@body.md --jq .html_url)
1738
-
1739
- gist:
1740
- ends in #gistcomment-<id>
1741
-
1742
- hn:
1743
- .../item?id=<your_new_item_id> (your id, not the parent's)
1744
-
1745
- reddit:
1746
- .../r/<sub>/comments/<thread>/<title>/<your_comment_id>/
1747
-
1748
- substack:
1749
- Substack permalinks don't always include the comment id, so for
1750
- this channel use --our-comment-id <id> from the response body.
1751
-
1752
- Escape hatch: --our-comment-id <id> tucks the id into metadata. Use it
1753
- when the URL pattern can't encode the comment (Substack, edge HN cases).`)
1754
- }
1755
-
1756
1796
  function contextUsage() {
1757
1797
  console.log(`autark context
1758
1798
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.5.8",
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": {