autark-cli 0.3.2 → 0.4.1

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 +109 -3
  2. package/package.json +1 -1
package/autark.mjs CHANGED
@@ -856,10 +856,109 @@ async function mail(command, rest) {
856
856
  if (command === 'raw') return mailRaw(rest)
857
857
  if (command === 'attachment') return mailAttachment(rest)
858
858
  if (command === 'request') return mailRequest(rest)
859
+ if (command === 'suppress') return mailSuppress(rest)
860
+ if (command === 'unsuppress') return mailUnsuppress(rest)
861
+ if (command === 'suppressed') return mailSuppressed(rest)
862
+ if (command === 'lint') return mailLint(rest)
859
863
  mailUsage()
860
864
  process.exit(1)
861
865
  }
862
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
+
940
+ async function mailSuppress(rest) {
941
+ const opts = parseArgs(rest)
942
+ const entry = required(opts.email || opts._[0], 'email')
943
+ const reason = opts.reason || 'manual'
944
+ const result = await api('POST', '/v1/suppression', { entry, reason })
945
+ printJson(result)
946
+ }
947
+
948
+ async function mailUnsuppress(rest) {
949
+ const opts = parseArgs(rest)
950
+ const entry = required(opts.email || opts._[0], 'email')
951
+ const result = await api('DELETE', `/v1/suppression/${encodeURIComponent(entry)}`)
952
+ printJson(result)
953
+ }
954
+
955
+ async function mailSuppressed(rest) {
956
+ const opts = parseArgs(rest)
957
+ const limit = opts.limit ? Number(opts.limit) : 100
958
+ const result = await api('GET', `/v1/suppression?limit=${limit}`)
959
+ printJson(result)
960
+ }
961
+
863
962
  async function mailSetup(rest) {
864
963
  const opts = parseArgs(rest)
865
964
  const existing = loadCredentials() || {}
@@ -1304,7 +1403,7 @@ function mailUsage() {
1304
1403
  console.log(`autark mail
1305
1404
 
1306
1405
  setup --prefix <name> [--force]
1307
- send --to <email[,email]> --subject <s> --text @body.txt [--run-id <id>]
1406
+ send --to <email[,email]> --subject <s> --text @body.txt [--run-id <id>] [--dry-run]
1308
1407
  reply --message-id <id> --text @reply.txt [--run-id <id>]
1309
1408
  reply-all --message-id <id> --text @reply.txt [--run-id <id>]
1310
1409
  forward --message-id <id> --to <email> [--text @body.txt] [--run-id <id>]
@@ -1315,8 +1414,15 @@ function mailUsage() {
1315
1414
  raw <message_id>
1316
1415
  attachment --message-id <id> --attachment-id <id> [--out file]
1317
1416
  request <METHOD> <path> [--body @json] [--raw]
1318
-
1319
- Mail uses the inbox-scoped token in ~/.autark/credentials.json.`)
1417
+ suppress <email> [--reason "<text>"] add to org-wide send-block list
1418
+ unsuppress <email> remove from send-block list
1419
+ suppressed [--limit N] list blocked addresses
1420
+ lint --body @draft.txt | <stdin> grade a draft for AI-tells; exit 1 on violations
1421
+
1422
+ Mail uses the inbox-scoped token in ~/.autark/credentials.json. Suppression
1423
+ verbs proxy through the autark worker (org-scoped). --dry-run prints the
1424
+ payload without hitting SES. lint is fully local — runs the outreach skill's
1425
+ hard-fail checks against a draft.`)
1320
1426
  }
1321
1427
 
1322
1428
  function usage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autark-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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",