autark-cli 0.4.0 → 0.4.2

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 (2) hide show
  1. package/autark.mjs +100 -2
  2. package/package.json +1 -1
package/autark.mjs CHANGED
@@ -859,10 +859,84 @@ async function mail(command, rest) {
859
859
  if (command === 'suppress') return mailSuppress(rest)
860
860
  if (command === 'unsuppress') return mailUnsuppress(rest)
861
861
  if (command === 'suppressed') return mailSuppressed(rest)
862
+ if (command === 'lint') return mailLint(rest)
862
863
  mailUsage()
863
864
  process.exit(1)
864
865
  }
865
866
 
867
+ // Read a draft from stdin or a @file path and grade against the AI-tells
868
+ // rules defined in the outreach skill. Exits 0 on clean, 1 on violations.
869
+ // The lint rules are intentionally aggressive and slightly opinionated —
870
+ // the goal is to make agents abstain on slop, not to nitpick humans.
871
+ async function mailLint(rest) {
872
+ const opts = parseArgs(rest)
873
+ let body = ''
874
+ if (opts.body && String(opts.body).startsWith('@')) {
875
+ body = fs.readFileSync(String(opts.body).slice(1), 'utf8')
876
+ } else if (opts._?.[0] && String(opts._[0]).startsWith('@')) {
877
+ body = fs.readFileSync(String(opts._[0]).slice(1), 'utf8')
878
+ } else if (!process.stdin.isTTY) {
879
+ body = await new Promise((resolve) => {
880
+ const chunks = []
881
+ process.stdin.on('data', (c) => chunks.push(c))
882
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
883
+ })
884
+ } else {
885
+ console.error('autark mail lint: pass a draft via --body @./draft.txt or pipe on stdin')
886
+ process.exit(2)
887
+ }
888
+
889
+ const STRUCTURE_WORDS = [
890
+ 'structurally', 'structurally different', 'fundamentally', 'specifically',
891
+ 'essentially', 'actually', 'notably',
892
+ 'the key insight', 'what this means is', 'the answer is', 'the trick is',
893
+ 'that\'s exactly', 'that\'s precisely', 'that\'s the whole point',
894
+ ]
895
+
896
+ const violations = []
897
+ const lower = body.toLowerCase()
898
+
899
+ // 1. structure-words
900
+ for (const w of STRUCTURE_WORDS) {
901
+ if (lower.includes(w)) violations.push({ rule: 'structure-word', detail: w })
902
+ }
903
+ // 2. em-dash
904
+ if (/—/.test(body)) violations.push({ rule: 'em-dash', detail: 'use a period or comma instead' })
905
+ // 3. URLs in body (allow only inside markdown anchor [text](url) or HTML <a href>)
906
+ const urlMatches = body.match(/https?:\/\/[^\s<>"')]+/g) || []
907
+ for (const u of urlMatches) {
908
+ const idx = body.indexOf(u)
909
+ const before = body.slice(Math.max(0, idx - 40), idx)
910
+ const after = body.slice(idx + u.length, idx + u.length + 10)
911
+ const insideMdAnchor = /\]\(\s*$/.test(before)
912
+ const insideHtmlAnchor = /href\s*=\s*["'][^"']*$/.test(before) || /<a\b[^>]*$/.test(before)
913
+ if (!insideMdAnchor && !insideHtmlAnchor) violations.push({ rule: 'body-url', detail: u })
914
+ }
915
+ // 4. multiple question marks (more than one ? in the body)
916
+ const qmarks = (body.match(/\?/g) || []).length
917
+ if (qmarks > 1) violations.push({ rule: 'too-many-questions', detail: `${qmarks} question marks; cold outreach gets one max` })
918
+ // 5. compound question with "or" connecting clauses
919
+ const compoundQ = /\b(are|is|do|does|did|will|would|can|could|should|have|has)\b[^?]{0,200}\bor\b[^?]{0,200}\?/i
920
+ if (compoundQ.test(body)) violations.push({ rule: 'compound-question', detail: 'a/b question with "or" splits attention; ask one thing' })
921
+ // 6. body length (excluding signature heuristically)
922
+ const sigSplit = body.split(/\n\s*\n(?:best|thanks|cheers|—|-)[\s,]*\n/i)[0] || body
923
+ const len = sigSplit.trim().length
924
+ if (len > 400) violations.push({ rule: 'too-long', detail: `${len} chars of body; cold outreach should fit in ~400` })
925
+ // 7. signature presence: should have `- <name>` and an anchor (md or html)
926
+ const hasMdAnchor = /\[[^\]]+\]\(https?:\/\/[^)]+\)/.test(body)
927
+ const hasHtmlAnchor = /<a\b[^>]*href\s*=\s*["']https?:\/\/[^"']+["'][^>]*>[^<]+<\/a>/i.test(body)
928
+ if (!hasMdAnchor && !hasHtmlAnchor) {
929
+ violations.push({ rule: 'no-anchor-sig', detail: 'signature must include a clickable link via [name](url) or <a href="url">name</a>' })
930
+ }
931
+
932
+ if (violations.length === 0) {
933
+ console.log(JSON.stringify({ clean: true, length: len }, null, 2))
934
+ return
935
+ }
936
+ console.error(JSON.stringify({ clean: false, length: len, violations }, null, 2))
937
+ process.exit(1)
938
+ }
939
+
866
940
  async function mailSuppress(rest) {
867
941
  const opts = parseArgs(rest)
868
942
  const entry = required(opts.email || opts._[0], 'email')
@@ -916,9 +990,10 @@ async function mailSend(rest) {
916
990
  rejectUnsendableAddress(to)
917
991
  const subject = opts.subject || ''
918
992
  const body = mailBody(opts, { to, subject })
993
+ const anchor = readAnchorOpts(opts)
919
994
  const dryRun = opts['dry-run'] === true || opts.dry_run === true
920
995
  if (dryRun) {
921
- printJson({ dry_run: true, would_send: { inbox: creds.inboxId, to, subject, body } })
996
+ printJson({ dry_run: true, would_send: { inbox: creds.inboxId, to, subject, body, anchor } })
922
997
  return
923
998
  }
924
999
  const result = await agentmailRequest('POST', `/inboxes/${encodeURIComponent(creds.inboxId)}/messages/send`, body)
@@ -928,10 +1003,30 @@ async function mailSend(rest) {
928
1003
  recipient: to.join(','),
929
1004
  subject,
930
1005
  response: result,
1006
+ metadata: anchor ? { anchor_quote: anchor.quote, anchor_url: anchor.url } : {},
931
1007
  })
932
1008
  printJson({ ...result, autark_action_id: action?.id })
933
1009
  }
934
1010
 
1011
+ // Optional anchor-quote primitive. The cold-outreach skill recommends that
1012
+ // every send carry a verbatim quote from the recipient's public surface
1013
+ // (HN comment, GitHub issue, tweet) plus the URL it was lifted from. The
1014
+ // CLI doesn't (yet) enforce — when both are present they're validated and
1015
+ // logged into the action's metadata; when absent the send still goes
1016
+ // through. Flip to required once the prompt-side discipline catches up.
1017
+ function readAnchorOpts(opts) {
1018
+ let quote = opts['anchor-quote'] || opts.anchor_quote
1019
+ const url = opts['anchor-url'] || opts.anchor_url
1020
+ if (quote && String(quote).startsWith('@')) {
1021
+ quote = fs.readFileSync(String(quote).slice(1), 'utf8').trim()
1022
+ }
1023
+ if (!quote && !url) return null
1024
+ if (!quote || !url) throw new Error('--anchor-quote and --anchor-url must both be provided (or both omitted)')
1025
+ if (String(quote).length < 30) throw new Error(`--anchor-quote too short (${String(quote).length} chars); need >=30 chars of verbatim recipient text`)
1026
+ if (!/^https?:\/\//.test(String(url))) throw new Error(`--anchor-url must be a full URL, got: ${url}`)
1027
+ return { quote: String(quote).trim(), url: String(url).trim() }
1028
+ }
1029
+
935
1030
  async function mailReply(rest, mode) {
936
1031
  const opts = parseArgs(rest)
937
1032
  const creds = requireAgentmailCredentials()
@@ -1330,6 +1425,7 @@ function mailUsage() {
1330
1425
 
1331
1426
  setup --prefix <name> [--force]
1332
1427
  send --to <email[,email]> --subject <s> --text @body.txt [--run-id <id>] [--dry-run]
1428
+ [--anchor-quote @./q.txt --anchor-url <url>] record the recipient's verbatim public quote
1333
1429
  reply --message-id <id> --text @reply.txt [--run-id <id>]
1334
1430
  reply-all --message-id <id> --text @reply.txt [--run-id <id>]
1335
1431
  forward --message-id <id> --to <email> [--text @body.txt] [--run-id <id>]
@@ -1343,10 +1439,12 @@ function mailUsage() {
1343
1439
  suppress <email> [--reason "<text>"] add to org-wide send-block list
1344
1440
  unsuppress <email> remove from send-block list
1345
1441
  suppressed [--limit N] list blocked addresses
1442
+ lint --body @draft.txt | <stdin> grade a draft for AI-tells; exit 1 on violations
1346
1443
 
1347
1444
  Mail uses the inbox-scoped token in ~/.autark/credentials.json. Suppression
1348
1445
  verbs proxy through the autark worker (org-scoped). --dry-run prints the
1349
- payload without hitting SES.`)
1446
+ payload without hitting SES. lint is fully local — runs the outreach skill's
1447
+ hard-fail checks against a draft.`)
1350
1448
  }
1351
1449
 
1352
1450
  function usage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "CLI for autark \u2014 hypothesis-driven product runbooks. Track products, hypotheses, runs, and actions from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",