autark-cli 0.4.0 → 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 +77 -1
  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')
@@ -1343,10 +1417,12 @@ function mailUsage() {
1343
1417
  suppress <email> [--reason "<text>"] add to org-wide send-block list
1344
1418
  unsuppress <email> remove from send-block list
1345
1419
  suppressed [--limit N] list blocked addresses
1420
+ lint --body @draft.txt | <stdin> grade a draft for AI-tells; exit 1 on violations
1346
1421
 
1347
1422
  Mail uses the inbox-scoped token in ~/.autark/credentials.json. Suppression
1348
1423
  verbs proxy through the autark worker (org-scoped). --dry-run prints the
1349
- payload without hitting SES.`)
1424
+ payload without hitting SES. lint is fully local — runs the outreach skill's
1425
+ hard-fail checks against a draft.`)
1350
1426
  }
1351
1427
 
1352
1428
  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.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",