@jonit-dev/night-watch-cli 1.7.29 → 1.7.31
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.
- package/bin/night-watch.mjs +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/{src/cli.js → cli.js} +1 -0
- package/dist/cli.js.map +1 -0
- package/dist/{src/commands → commands}/audit.d.ts +2 -2
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +105 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/{src/commands → commands}/board.d.ts +1 -1
- package/dist/commands/board.d.ts.map +1 -0
- package/dist/commands/board.js +664 -0
- package/dist/commands/board.js.map +1 -0
- package/dist/{src/commands → commands}/cancel.d.ts +3 -3
- package/dist/commands/cancel.d.ts.map +1 -0
- package/dist/{src/commands → commands}/cancel.js +18 -20
- package/dist/commands/cancel.js.map +1 -0
- package/dist/commands/dashboard/tab-actions.d.ts.map +1 -0
- package/dist/commands/dashboard/tab-actions.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-config.d.ts +3 -3
- package/dist/commands/dashboard/tab-config.d.ts.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-config.js +250 -187
- package/dist/commands/dashboard/tab-config.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-logs.d.ts +1 -1
- package/dist/commands/dashboard/tab-logs.d.ts.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-logs.js +62 -38
- package/dist/commands/dashboard/tab-logs.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-schedules.d.ts +1 -1
- package/dist/commands/dashboard/tab-schedules.d.ts.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-schedules.js +85 -76
- package/dist/commands/dashboard/tab-schedules.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-status.d.ts +7 -7
- package/dist/commands/dashboard/tab-status.d.ts.map +1 -0
- package/dist/{src/commands → commands}/dashboard/tab-status.js +98 -95
- package/dist/commands/dashboard/tab-status.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard/types.d.ts +3 -4
- package/dist/commands/dashboard/types.d.ts.map +1 -0
- package/dist/commands/dashboard/types.js.map +1 -0
- package/dist/{src/commands → commands}/dashboard.d.ts +2 -2
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/{src/commands → commands}/dashboard.js +32 -33
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/{src/commands → commands}/doctor.d.ts +2 -2
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/{src/commands → commands}/doctor.js +40 -43
- package/dist/commands/doctor.js.map +1 -0
- package/dist/{src/commands → commands}/history.d.ts +1 -1
- package/dist/commands/history.d.ts.map +1 -0
- package/dist/{src/commands → commands}/history.js +11 -18
- package/dist/commands/history.js.map +1 -0
- package/dist/{src/commands → commands}/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/{src/commands → commands}/init.js +62 -36
- package/dist/commands/init.js.map +1 -0
- package/dist/{src/commands → commands}/install.d.ts +2 -2
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/{src/commands → commands}/install.js +48 -50
- package/dist/commands/install.js.map +1 -0
- package/dist/{src/commands → commands}/logs.d.ts +1 -1
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/{src/commands → commands}/logs.js +29 -30
- package/dist/commands/logs.js.map +1 -0
- package/dist/{src/commands → commands}/prd-state.d.ts +1 -1
- package/dist/commands/prd-state.d.ts.map +1 -0
- package/dist/{src/commands → commands}/prd-state.js +14 -14
- package/dist/commands/prd-state.js.map +1 -0
- package/dist/{src/commands → commands}/prd.d.ts +1 -1
- package/dist/commands/prd.d.ts.map +1 -0
- package/dist/{src/commands → commands}/prd.js +57 -66
- package/dist/commands/prd.js.map +1 -0
- package/dist/{src/commands → commands}/prds.d.ts +1 -1
- package/dist/commands/prds.d.ts.map +1 -0
- package/dist/{src/commands → commands}/prds.js +51 -53
- package/dist/commands/prds.js.map +1 -0
- package/dist/{src/commands → commands}/prs.d.ts +1 -1
- package/dist/commands/prs.d.ts.map +1 -0
- package/dist/{src/commands → commands}/prs.js +22 -24
- package/dist/commands/prs.js.map +1 -0
- package/dist/{src/commands → commands}/qa.d.ts +2 -2
- package/dist/commands/qa.d.ts.map +1 -0
- package/dist/{src/commands → commands}/qa.js +50 -51
- package/dist/commands/qa.js.map +1 -0
- package/dist/{src/commands → commands}/retry.d.ts +1 -1
- package/dist/commands/retry.d.ts.map +1 -0
- package/dist/{src/commands → commands}/retry.js +9 -10
- package/dist/commands/retry.js.map +1 -0
- package/dist/{src/commands → commands}/review.d.ts +2 -2
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/{src/commands → commands}/review.js +68 -59
- package/dist/commands/review.js.map +1 -0
- package/dist/{src/commands → commands}/run.d.ts +2 -2
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/{src/commands → commands}/run.js +87 -83
- package/dist/commands/run.js.map +1 -0
- package/dist/{src/commands → commands}/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/{src/commands → commands}/serve.js +18 -18
- package/dist/commands/serve.js.map +1 -0
- package/dist/{src/commands → commands}/slice.d.ts +2 -2
- package/dist/commands/slice.d.ts.map +1 -0
- package/dist/{src/commands → commands}/slice.js +50 -46
- package/dist/commands/slice.js.map +1 -0
- package/dist/{src/commands → commands}/state.d.ts +1 -1
- package/dist/commands/state.d.ts.map +1 -0
- package/dist/{src/commands → commands}/state.js +20 -22
- package/dist/commands/state.js.map +1 -0
- package/dist/{src/commands → commands}/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/{src/commands → commands}/status.js +75 -54
- package/dist/commands/status.js.map +1 -0
- package/dist/{src/commands → commands}/uninstall.d.ts +1 -1
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/{src/commands → commands}/uninstall.js +12 -14
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/{src/commands → commands}/update.d.ts +1 -1
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/{src/commands → commands}/update.js +23 -23
- package/dist/commands/update.js.map +1 -0
- package/package.json +18 -42
- package/LICENSE +0 -21
- package/README.md +0 -132
- package/dist/shared/types.d.ts +0 -226
- package/dist/shared/types.d.ts.map +0 -1
- package/dist/shared/types.js +0 -7
- package/dist/shared/types.js.map +0 -1
- package/dist/src/agents/soul-compiler.d.ts +0 -11
- package/dist/src/agents/soul-compiler.d.ts.map +0 -1
- package/dist/src/agents/soul-compiler.js +0 -157
- package/dist/src/agents/soul-compiler.js.map +0 -1
- package/dist/src/board/factory.d.ts +0 -3
- package/dist/src/board/factory.d.ts.map +0 -1
- package/dist/src/board/factory.js +0 -10
- package/dist/src/board/factory.js.map +0 -1
- package/dist/src/board/providers/github-graphql.d.ts +0 -16
- package/dist/src/board/providers/github-graphql.d.ts.map +0 -1
- package/dist/src/board/providers/github-graphql.js +0 -43
- package/dist/src/board/providers/github-graphql.js.map +0 -1
- package/dist/src/board/providers/github-projects.d.ts +0 -51
- package/dist/src/board/providers/github-projects.d.ts.map +0 -1
- package/dist/src/board/providers/github-projects.js +0 -672
- package/dist/src/board/providers/github-projects.js.map +0 -1
- package/dist/src/board/types.d.ts +0 -60
- package/dist/src/board/types.d.ts.map +0 -1
- package/dist/src/board/types.js +0 -4
- package/dist/src/board/types.js.map +0 -1
- package/dist/src/cli.d.ts +0 -3
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/commands/audit.d.ts.map +0 -1
- package/dist/src/commands/audit.js +0 -98
- package/dist/src/commands/audit.js.map +0 -1
- package/dist/src/commands/board.d.ts.map +0 -1
- package/dist/src/commands/board.js +0 -294
- package/dist/src/commands/board.js.map +0 -1
- package/dist/src/commands/cancel.d.ts.map +0 -1
- package/dist/src/commands/cancel.js.map +0 -1
- package/dist/src/commands/dashboard/tab-actions.d.ts.map +0 -1
- package/dist/src/commands/dashboard/tab-actions.js.map +0 -1
- package/dist/src/commands/dashboard/tab-config.d.ts.map +0 -1
- package/dist/src/commands/dashboard/tab-config.js.map +0 -1
- package/dist/src/commands/dashboard/tab-logs.d.ts.map +0 -1
- package/dist/src/commands/dashboard/tab-logs.js.map +0 -1
- package/dist/src/commands/dashboard/tab-schedules.d.ts.map +0 -1
- package/dist/src/commands/dashboard/tab-schedules.js.map +0 -1
- package/dist/src/commands/dashboard/tab-status.d.ts.map +0 -1
- package/dist/src/commands/dashboard/tab-status.js.map +0 -1
- package/dist/src/commands/dashboard/types.d.ts.map +0 -1
- package/dist/src/commands/dashboard/types.js.map +0 -1
- package/dist/src/commands/dashboard.d.ts.map +0 -1
- package/dist/src/commands/dashboard.js.map +0 -1
- package/dist/src/commands/doctor.d.ts.map +0 -1
- package/dist/src/commands/doctor.js.map +0 -1
- package/dist/src/commands/history.d.ts.map +0 -1
- package/dist/src/commands/history.js.map +0 -1
- package/dist/src/commands/init.d.ts.map +0 -1
- package/dist/src/commands/init.js.map +0 -1
- package/dist/src/commands/install.d.ts.map +0 -1
- package/dist/src/commands/install.js.map +0 -1
- package/dist/src/commands/logs.d.ts.map +0 -1
- package/dist/src/commands/logs.js.map +0 -1
- package/dist/src/commands/prd-state.d.ts.map +0 -1
- package/dist/src/commands/prd-state.js.map +0 -1
- package/dist/src/commands/prd.d.ts.map +0 -1
- package/dist/src/commands/prd.js.map +0 -1
- package/dist/src/commands/prds.d.ts.map +0 -1
- package/dist/src/commands/prds.js.map +0 -1
- package/dist/src/commands/prs.d.ts.map +0 -1
- package/dist/src/commands/prs.js.map +0 -1
- package/dist/src/commands/qa.d.ts.map +0 -1
- package/dist/src/commands/qa.js.map +0 -1
- package/dist/src/commands/retry.d.ts.map +0 -1
- package/dist/src/commands/retry.js.map +0 -1
- package/dist/src/commands/review.d.ts.map +0 -1
- package/dist/src/commands/review.js.map +0 -1
- package/dist/src/commands/run.d.ts.map +0 -1
- package/dist/src/commands/run.js.map +0 -1
- package/dist/src/commands/serve.d.ts.map +0 -1
- package/dist/src/commands/serve.js.map +0 -1
- package/dist/src/commands/slice.d.ts.map +0 -1
- package/dist/src/commands/slice.js.map +0 -1
- package/dist/src/commands/state.d.ts.map +0 -1
- package/dist/src/commands/state.js.map +0 -1
- package/dist/src/commands/status.d.ts.map +0 -1
- package/dist/src/commands/status.js.map +0 -1
- package/dist/src/commands/uninstall.d.ts.map +0 -1
- package/dist/src/commands/uninstall.js.map +0 -1
- package/dist/src/commands/update.d.ts.map +0 -1
- package/dist/src/commands/update.js.map +0 -1
- package/dist/src/config.d.ts +0 -23
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -671
- package/dist/src/config.js.map +0 -1
- package/dist/src/constants.d.ts +0 -67
- package/dist/src/constants.d.ts.map +0 -1
- package/dist/src/constants.js +0 -131
- package/dist/src/constants.js.map +0 -1
- package/dist/src/server/index.d.ts +0 -23
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -1704
- package/dist/src/server/index.js.map +0 -1
- package/dist/src/slack/channel-manager.d.ts +0 -32
- package/dist/src/slack/channel-manager.d.ts.map +0 -1
- package/dist/src/slack/channel-manager.js +0 -128
- package/dist/src/slack/channel-manager.js.map +0 -1
- package/dist/src/slack/client.d.ts +0 -76
- package/dist/src/slack/client.d.ts.map +0 -1
- package/dist/src/slack/client.js +0 -193
- package/dist/src/slack/client.js.map +0 -1
- package/dist/src/slack/deliberation.d.ts +0 -87
- package/dist/src/slack/deliberation.d.ts.map +0 -1
- package/dist/src/slack/deliberation.js +0 -1354
- package/dist/src/slack/deliberation.js.map +0 -1
- package/dist/src/slack/index.d.ts +0 -6
- package/dist/src/slack/index.d.ts.map +0 -1
- package/dist/src/slack/index.js +0 -5
- package/dist/src/slack/index.js.map +0 -1
- package/dist/src/slack/interaction-listener.d.ts +0 -130
- package/dist/src/slack/interaction-listener.d.ts.map +0 -1
- package/dist/src/slack/interaction-listener.js +0 -1329
- package/dist/src/slack/interaction-listener.js.map +0 -1
- package/dist/src/storage/json-state-migrator.d.ts +0 -24
- package/dist/src/storage/json-state-migrator.d.ts.map +0 -1
- package/dist/src/storage/json-state-migrator.js +0 -197
- package/dist/src/storage/json-state-migrator.js.map +0 -1
- package/dist/src/storage/repositories/index.d.ts +0 -25
- package/dist/src/storage/repositories/index.d.ts.map +0 -1
- package/dist/src/storage/repositories/index.js +0 -45
- package/dist/src/storage/repositories/index.js.map +0 -1
- package/dist/src/storage/repositories/interfaces.d.ts +0 -60
- package/dist/src/storage/repositories/interfaces.d.ts.map +0 -1
- package/dist/src/storage/repositories/interfaces.js +0 -6
- package/dist/src/storage/repositories/interfaces.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +0 -33
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +0 -715
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/execution-history-repository.d.ts +0 -21
- package/dist/src/storage/repositories/sqlite/execution-history-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/execution-history-repository.js +0 -94
- package/dist/src/storage/repositories/sqlite/execution-history-repository.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/prd-state-repository.d.ts +0 -17
- package/dist/src/storage/repositories/sqlite/prd-state-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/prd-state-repository.js +0 -74
- package/dist/src/storage/repositories/sqlite/prd-state-repository.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/project-registry-repository.d.ts +0 -17
- package/dist/src/storage/repositories/sqlite/project-registry-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/project-registry-repository.js +0 -43
- package/dist/src/storage/repositories/sqlite/project-registry-repository.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/roadmap-state-repository.d.ts +0 -14
- package/dist/src/storage/repositories/sqlite/roadmap-state-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/roadmap-state-repository.js +0 -47
- package/dist/src/storage/repositories/sqlite/roadmap-state-repository.js.map +0 -1
- package/dist/src/storage/repositories/sqlite/slack-discussion-repository.d.ts +0 -20
- package/dist/src/storage/repositories/sqlite/slack-discussion-repository.d.ts.map +0 -1
- package/dist/src/storage/repositories/sqlite/slack-discussion-repository.js +0 -88
- package/dist/src/storage/repositories/sqlite/slack-discussion-repository.js.map +0 -1
- package/dist/src/storage/sqlite/client.d.ts +0 -23
- package/dist/src/storage/sqlite/client.d.ts.map +0 -1
- package/dist/src/storage/sqlite/client.js +0 -47
- package/dist/src/storage/sqlite/client.js.map +0 -1
- package/dist/src/storage/sqlite/migrations.d.ts +0 -11
- package/dist/src/storage/sqlite/migrations.d.ts.map +0 -1
- package/dist/src/storage/sqlite/migrations.js +0 -94
- package/dist/src/storage/sqlite/migrations.js.map +0 -1
- package/dist/src/templates/prd-template.d.ts +0 -11
- package/dist/src/templates/prd-template.d.ts.map +0 -1
- package/dist/src/templates/prd-template.js +0 -166
- package/dist/src/templates/prd-template.js.map +0 -1
- package/dist/src/templates/slicer-prompt.d.ts +0 -54
- package/dist/src/templates/slicer-prompt.d.ts.map +0 -1
- package/dist/src/templates/slicer-prompt.js +0 -163
- package/dist/src/templates/slicer-prompt.js.map +0 -1
- package/dist/src/types.d.ts +0 -140
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -5
- package/dist/src/types.js.map +0 -1
- package/dist/src/utils/avatar-generator.d.ts +0 -6
- package/dist/src/utils/avatar-generator.d.ts.map +0 -1
- package/dist/src/utils/avatar-generator.js +0 -133
- package/dist/src/utils/avatar-generator.js.map +0 -1
- package/dist/src/utils/checks.d.ts +0 -55
- package/dist/src/utils/checks.d.ts.map +0 -1
- package/dist/src/utils/checks.js +0 -246
- package/dist/src/utils/checks.js.map +0 -1
- package/dist/src/utils/config-writer.d.ts +0 -16
- package/dist/src/utils/config-writer.d.ts.map +0 -1
- package/dist/src/utils/config-writer.js +0 -45
- package/dist/src/utils/config-writer.js.map +0 -1
- package/dist/src/utils/crontab.d.ts +0 -62
- package/dist/src/utils/crontab.d.ts.map +0 -1
- package/dist/src/utils/crontab.js +0 -168
- package/dist/src/utils/crontab.js.map +0 -1
- package/dist/src/utils/execution-history.d.ts +0 -54
- package/dist/src/utils/execution-history.d.ts.map +0 -1
- package/dist/src/utils/execution-history.js +0 -80
- package/dist/src/utils/execution-history.js.map +0 -1
- package/dist/src/utils/github.d.ts +0 -40
- package/dist/src/utils/github.d.ts.map +0 -1
- package/dist/src/utils/github.js +0 -126
- package/dist/src/utils/github.js.map +0 -1
- package/dist/src/utils/notify.d.ts +0 -64
- package/dist/src/utils/notify.d.ts.map +0 -1
- package/dist/src/utils/notify.js +0 -405
- package/dist/src/utils/notify.js.map +0 -1
- package/dist/src/utils/prd-states.d.ts +0 -16
- package/dist/src/utils/prd-states.d.ts.map +0 -1
- package/dist/src/utils/prd-states.js +0 -28
- package/dist/src/utils/prd-states.js.map +0 -1
- package/dist/src/utils/registry.d.ts +0 -45
- package/dist/src/utils/registry.d.ts.map +0 -1
- package/dist/src/utils/registry.js +0 -86
- package/dist/src/utils/registry.js.map +0 -1
- package/dist/src/utils/roadmap-parser.d.ts +0 -45
- package/dist/src/utils/roadmap-parser.d.ts.map +0 -1
- package/dist/src/utils/roadmap-parser.js +0 -136
- package/dist/src/utils/roadmap-parser.js.map +0 -1
- package/dist/src/utils/roadmap-scanner.d.ts +0 -92
- package/dist/src/utils/roadmap-scanner.d.ts.map +0 -1
- package/dist/src/utils/roadmap-scanner.js +0 -349
- package/dist/src/utils/roadmap-scanner.js.map +0 -1
- package/dist/src/utils/roadmap-state.d.ts +0 -90
- package/dist/src/utils/roadmap-state.d.ts.map +0 -1
- package/dist/src/utils/roadmap-state.js +0 -154
- package/dist/src/utils/roadmap-state.js.map +0 -1
- package/dist/src/utils/script-result.d.ts +0 -12
- package/dist/src/utils/script-result.d.ts.map +0 -1
- package/dist/src/utils/script-result.js +0 -46
- package/dist/src/utils/script-result.js.map +0 -1
- package/dist/src/utils/shell.d.ts +0 -27
- package/dist/src/utils/shell.d.ts.map +0 -1
- package/dist/src/utils/shell.js +0 -64
- package/dist/src/utils/shell.js.map +0 -1
- package/dist/src/utils/status-data.d.ts +0 -148
- package/dist/src/utils/status-data.d.ts.map +0 -1
- package/dist/src/utils/status-data.js +0 -548
- package/dist/src/utils/status-data.js.map +0 -1
- package/dist/src/utils/ui.d.ts +0 -55
- package/dist/src/utils/ui.d.ts.map +0 -1
- package/dist/src/utils/ui.js +0 -121
- package/dist/src/utils/ui.js.map +0 -1
- package/scripts/night-watch-audit-cron.sh +0 -149
- package/scripts/night-watch-cron.sh +0 -484
- package/scripts/night-watch-helpers.sh +0 -499
- package/scripts/night-watch-pr-reviewer-cron.sh +0 -528
- package/scripts/night-watch-qa-cron.sh +0 -281
- package/scripts/night-watch-slicer-cron.sh +0 -90
- package/scripts/test-helpers.bats +0 -77
- package/templates/night-watch-pr-reviewer.md +0 -174
- package/templates/night-watch-qa.md +0 -157
- package/templates/night-watch-slicer.md +0 -219
- package/templates/night-watch.config.json +0 -30
- package/templates/night-watch.md +0 -94
- package/templates/prd-executor.md +0 -235
- package/templates/prd.md +0 -26
- package/web/dist/assets/index-BiJf9LFT.js +0 -458
- package/web/dist/assets/index-OpSgvsYu.css +0 -1
- package/web/dist/avatars/carlos.webp +0 -0
- package/web/dist/avatars/dev.webp +0 -0
- package/web/dist/avatars/maya.webp +0 -0
- package/web/dist/avatars/priya.webp +0 -0
- package/web/dist/index.html +0 -82
- /package/dist/{src/commands → commands}/dashboard/tab-actions.d.ts +0 -0
- /package/dist/{src/commands → commands}/dashboard/tab-actions.js +0 -0
- /package/dist/{src/commands → commands}/dashboard/types.js +0 -0
|
@@ -1,1354 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deliberation Engine for Night Watch.
|
|
3
|
-
* Orchestrates multi-agent Slack discussions when trigger events occur.
|
|
4
|
-
* Agents discuss in threads, reach consensus, and drive PR actions.
|
|
5
|
-
*/
|
|
6
|
-
import { compileSoul } from "../agents/soul-compiler.js";
|
|
7
|
-
import { getRepositories } from "../storage/repositories/index.js";
|
|
8
|
-
import { loadConfig } from "../config.js";
|
|
9
|
-
import { createBoardProvider } from "../board/factory.js";
|
|
10
|
-
import { execFileSync } from "node:child_process";
|
|
11
|
-
const MAX_ROUNDS = 2;
|
|
12
|
-
const MAX_CONTRIBUTIONS_PER_ROUND = 2;
|
|
13
|
-
const MAX_AGENT_THREAD_REPLIES = 4;
|
|
14
|
-
const HUMAN_DELAY_MIN_MS = 20_000; // Minimum pause between agent replies (20s)
|
|
15
|
-
const HUMAN_DELAY_MAX_MS = 60_000; // Maximum pause between agent replies (60s)
|
|
16
|
-
const DISCUSSION_RESUME_DELAY_MS = 60_000;
|
|
17
|
-
const DISCUSSION_REPLAY_GUARD_MS = 30 * 60_000;
|
|
18
|
-
const MAX_HUMANIZED_SENTENCES = 2;
|
|
19
|
-
const MAX_HUMANIZED_CHARS = 220;
|
|
20
|
-
const inFlightDiscussionStarts = new Map();
|
|
21
|
-
function discussionStartKey(trigger) {
|
|
22
|
-
return `${trigger.projectPath}:${trigger.type}:${trigger.ref}`;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Wait for the specified milliseconds
|
|
26
|
-
*/
|
|
27
|
-
function sleep(ms) {
|
|
28
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Return a random delay in the human-like range so replies don't arrive
|
|
32
|
-
* in an obviously robotic cadence.
|
|
33
|
-
*/
|
|
34
|
-
function humanDelay() {
|
|
35
|
-
return HUMAN_DELAY_MIN_MS + Math.random() * (HUMAN_DELAY_MAX_MS - HUMAN_DELAY_MIN_MS);
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Determine which Slack channel to use for a trigger type
|
|
39
|
-
*/
|
|
40
|
-
function getChannelForTrigger(trigger, config) {
|
|
41
|
-
const slack = config.slack;
|
|
42
|
-
if (!slack)
|
|
43
|
-
return '';
|
|
44
|
-
// Use explicitly provided channelId if given (e.g., for project-specific channels)
|
|
45
|
-
if (trigger.channelId)
|
|
46
|
-
return trigger.channelId;
|
|
47
|
-
switch (trigger.type) {
|
|
48
|
-
case 'pr_review':
|
|
49
|
-
return slack.channels.prs;
|
|
50
|
-
case 'build_failure':
|
|
51
|
-
return slack.channels.incidents;
|
|
52
|
-
case 'prd_kickoff':
|
|
53
|
-
return slack.channels.eng; // Callers should populate trigger.channelId with proj channel
|
|
54
|
-
case 'code_watch':
|
|
55
|
-
return slack.channels.eng;
|
|
56
|
-
default:
|
|
57
|
-
return slack.channels.eng;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Find a persona by explicit name first, then by role keyword.
|
|
62
|
-
*/
|
|
63
|
-
function findPersona(personas, names, roleKeywords) {
|
|
64
|
-
const byName = personas.find((p) => names.some((name) => p.name.toLowerCase() === name.toLowerCase()));
|
|
65
|
-
if (byName)
|
|
66
|
-
return byName;
|
|
67
|
-
return (personas.find((p) => {
|
|
68
|
-
const role = p.role.toLowerCase();
|
|
69
|
-
return roleKeywords.some((keyword) => role.includes(keyword.toLowerCase()));
|
|
70
|
-
}) ?? null);
|
|
71
|
-
}
|
|
72
|
-
function findDev(personas) {
|
|
73
|
-
return findPersona(personas, ["Dev"], ["implementer", "executor", "developer"]);
|
|
74
|
-
}
|
|
75
|
-
function findCarlos(personas) {
|
|
76
|
-
return findPersona(personas, ["Carlos"], ["tech lead", "architect", "lead"]);
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Determine which personas should participate based on trigger type.
|
|
80
|
-
* Uses role-based fallback so renamed personas still participate.
|
|
81
|
-
*/
|
|
82
|
-
function getParticipatingPersonas(triggerType, personas) {
|
|
83
|
-
const dev = findDev(personas);
|
|
84
|
-
const carlos = findCarlos(personas);
|
|
85
|
-
const maya = findPersona(personas, ["Maya"], ["security reviewer", "security"]);
|
|
86
|
-
const priya = findPersona(personas, ["Priya"], ["qa", "quality assurance", "test"]);
|
|
87
|
-
const set = new Map();
|
|
88
|
-
const add = (persona) => {
|
|
89
|
-
if (persona)
|
|
90
|
-
set.set(persona.id, persona);
|
|
91
|
-
};
|
|
92
|
-
switch (triggerType) {
|
|
93
|
-
case 'pr_review':
|
|
94
|
-
add(dev);
|
|
95
|
-
add(carlos);
|
|
96
|
-
add(maya);
|
|
97
|
-
add(priya);
|
|
98
|
-
break;
|
|
99
|
-
case 'build_failure':
|
|
100
|
-
add(dev);
|
|
101
|
-
add(carlos);
|
|
102
|
-
break;
|
|
103
|
-
case 'prd_kickoff':
|
|
104
|
-
add(dev);
|
|
105
|
-
add(carlos);
|
|
106
|
-
break;
|
|
107
|
-
case 'code_watch':
|
|
108
|
-
add(dev);
|
|
109
|
-
add(carlos);
|
|
110
|
-
add(maya);
|
|
111
|
-
add(priya);
|
|
112
|
-
break;
|
|
113
|
-
default:
|
|
114
|
-
add(carlos);
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
if (set.size === 0 && personas[0]) {
|
|
118
|
-
set.set(personas[0].id, personas[0]);
|
|
119
|
-
}
|
|
120
|
-
return Array.from(set.values());
|
|
121
|
-
}
|
|
122
|
-
function joinBaseUrl(baseUrl, route) {
|
|
123
|
-
return `${baseUrl.replace(/\/+$/, '')}${route}`;
|
|
124
|
-
}
|
|
125
|
-
function resolveGlobalAIConfig(config) {
|
|
126
|
-
const globalEnv = config.providerEnv ?? {};
|
|
127
|
-
if (config.provider === 'claude') {
|
|
128
|
-
return {
|
|
129
|
-
provider: 'anthropic',
|
|
130
|
-
model: config.claudeModel === 'opus' ? 'claude-opus-4-6' : 'claude-sonnet-4-6',
|
|
131
|
-
baseUrl: globalEnv.ANTHROPIC_BASE_URL ?? process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
|
|
132
|
-
envVars: globalEnv,
|
|
133
|
-
maxTokens: 256,
|
|
134
|
-
temperature: 0.8,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
provider: 'openai',
|
|
139
|
-
model: globalEnv.OPENAI_MODEL ?? process.env.OPENAI_MODEL ?? 'gpt-4o',
|
|
140
|
-
baseUrl: globalEnv.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com',
|
|
141
|
-
envVars: globalEnv,
|
|
142
|
-
maxTokens: 256,
|
|
143
|
-
temperature: 0.8,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
function resolvePersonaAIConfig(persona, config) {
|
|
147
|
-
const modelConfig = persona.modelConfig;
|
|
148
|
-
if (!modelConfig) {
|
|
149
|
-
return resolveGlobalAIConfig(config);
|
|
150
|
-
}
|
|
151
|
-
const globalEnv = config.providerEnv ?? {};
|
|
152
|
-
const envVars = { ...globalEnv, ...(modelConfig.envVars ?? {}) };
|
|
153
|
-
const isAnthropic = modelConfig.provider === 'anthropic';
|
|
154
|
-
return {
|
|
155
|
-
provider: isAnthropic ? 'anthropic' : 'openai',
|
|
156
|
-
model: modelConfig.model,
|
|
157
|
-
baseUrl: isAnthropic
|
|
158
|
-
? modelConfig.baseUrl ?? globalEnv.ANTHROPIC_BASE_URL ?? process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
|
|
159
|
-
: modelConfig.baseUrl ?? globalEnv.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com',
|
|
160
|
-
envVars,
|
|
161
|
-
maxTokens: modelConfig.maxTokens ?? 256,
|
|
162
|
-
temperature: modelConfig.temperature ?? 0.8,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Generate the opening message text for a discussion
|
|
167
|
-
*/
|
|
168
|
-
function buildOpeningMessage(trigger) {
|
|
169
|
-
switch (trigger.type) {
|
|
170
|
-
case 'pr_review':
|
|
171
|
-
return `Opened ${trigger.ref}${trigger.prUrl ? ` — ${trigger.prUrl}` : ''}. Ready for eyes.`;
|
|
172
|
-
case 'build_failure':
|
|
173
|
-
return `Build broke on ${trigger.ref}. Looking into it.\n\n${trigger.context.slice(0, 500)}`;
|
|
174
|
-
case 'prd_kickoff':
|
|
175
|
-
return `Picking up ${trigger.ref}. Going to start carving out the implementation.`;
|
|
176
|
-
case 'code_watch': {
|
|
177
|
-
// Parse context fields to compose a natural message rather than dumping structured data.
|
|
178
|
-
const locationMatch = trigger.context.match(/^Location: (.+)$/m);
|
|
179
|
-
const signalMatch = trigger.context.match(/^Signal: (.+)$/m);
|
|
180
|
-
const snippetMatch = trigger.context.match(/^Snippet: (.+)$/m);
|
|
181
|
-
const location = locationMatch?.[1]?.trim() ?? '';
|
|
182
|
-
const signal = signalMatch?.[1]?.trim() ?? '';
|
|
183
|
-
const snippet = snippetMatch?.[1]?.trim() ?? '';
|
|
184
|
-
if (location && signal) {
|
|
185
|
-
const DETAIL_OPENERS = [
|
|
186
|
-
`${location} — ${signal}.`,
|
|
187
|
-
`Flagging ${location}: ${signal}.`,
|
|
188
|
-
`Caught something in ${location}: ${signal}.`,
|
|
189
|
-
`${location} pinged the scanner — ${signal}.`,
|
|
190
|
-
`Noticed this in ${location}: ${signal}.`,
|
|
191
|
-
];
|
|
192
|
-
const hash = trigger.ref.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
193
|
-
const opener = DETAIL_OPENERS[hash % DETAIL_OPENERS.length];
|
|
194
|
-
return snippet ? `${opener}\n\`\`\`\n${snippet}\n\`\`\`` : opener;
|
|
195
|
-
}
|
|
196
|
-
return trigger.context.slice(0, 600);
|
|
197
|
-
}
|
|
198
|
-
default:
|
|
199
|
-
return trigger.context.slice(0, 500);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Parse the structured code_watch context string and derive a git-style issue title.
|
|
204
|
-
*/
|
|
205
|
-
function buildIssueTitleFromTrigger(trigger) {
|
|
206
|
-
const signalMatch = trigger.context.match(/^Signal: (.+)$/m);
|
|
207
|
-
const locationMatch = trigger.context.match(/^Location: (.+)$/m);
|
|
208
|
-
const signal = signalMatch?.[1] ?? 'code signal';
|
|
209
|
-
const location = locationMatch?.[1] ?? 'unknown location';
|
|
210
|
-
return `fix: ${signal} at ${location}`;
|
|
211
|
-
}
|
|
212
|
-
function hasConcreteCodeContext(context) {
|
|
213
|
-
return (/```/.test(context)
|
|
214
|
-
|| /(^|\s)(src|test|scripts|web)\/[^\s:]+\.[A-Za-z0-9]+(?::\d+)?/.test(context)
|
|
215
|
-
|| /\bdiff --git\b/.test(context)
|
|
216
|
-
|| /@@\s[-+]\d+/.test(context)
|
|
217
|
-
|| /\b(function|class|const|let|if\s*\(|try\s*{|catch\s*\()/.test(context));
|
|
218
|
-
}
|
|
219
|
-
function loadPrDiffExcerpt(projectPath, ref) {
|
|
220
|
-
const prNumber = Number.parseInt(ref, 10);
|
|
221
|
-
if (Number.isNaN(prNumber))
|
|
222
|
-
return '';
|
|
223
|
-
try {
|
|
224
|
-
const diff = execFileSync('gh', ['pr', 'diff', String(prNumber), '--color=never'], {
|
|
225
|
-
cwd: projectPath,
|
|
226
|
-
encoding: 'utf8',
|
|
227
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
228
|
-
maxBuffer: 2 * 1024 * 1024,
|
|
229
|
-
});
|
|
230
|
-
const excerpt = diff
|
|
231
|
-
.split('\n')
|
|
232
|
-
.slice(0, 160)
|
|
233
|
-
.join('\n')
|
|
234
|
-
.trim();
|
|
235
|
-
if (!excerpt)
|
|
236
|
-
return '';
|
|
237
|
-
return `PR diff excerpt (first 160 lines):\n\`\`\`diff\n${excerpt}\n\`\`\``;
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return '';
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Build the contribution prompt for an agent's AI call.
|
|
245
|
-
* This is what gets sent to the AI provider to generate the agent's message.
|
|
246
|
-
*/
|
|
247
|
-
function buildContributionPrompt(persona, trigger, threadHistory, round) {
|
|
248
|
-
const isFirstRound = round === 1;
|
|
249
|
-
const isFinalRound = round >= MAX_ROUNDS;
|
|
250
|
-
return `You are ${persona.name}, ${persona.role}.
|
|
251
|
-
You're in a Slack thread with your teammates — Dev (implementer), Carlos (tech lead), Maya (security), and Priya (QA). This is a real conversation, not a report.
|
|
252
|
-
|
|
253
|
-
Trigger: ${trigger.type} — ${trigger.ref}
|
|
254
|
-
Round: ${round}/${MAX_ROUNDS}${isFinalRound ? ' (final round — wrap up)' : ''}
|
|
255
|
-
|
|
256
|
-
## Context
|
|
257
|
-
${trigger.context.slice(0, 2000)}
|
|
258
|
-
|
|
259
|
-
## Thread So Far
|
|
260
|
-
${threadHistory || '(Thread just started)'}
|
|
261
|
-
|
|
262
|
-
## How to respond
|
|
263
|
-
Write a short Slack message — 1 to 2 sentences max, under ~180 chars when possible.
|
|
264
|
-
${isFirstRound ? '- First round: give your initial take from your angle. Be specific.' : '- Follow-up round: respond to what others said. Agree, push back, or add something new.'}
|
|
265
|
-
- React to one specific point already in the thread (use teammate names when available).
|
|
266
|
-
- Never repeat a point that's already been made in similar words.
|
|
267
|
-
- Back your take with one concrete artifact from context (file path, symbol, diff hunk, or log line).
|
|
268
|
-
- If context lacks concrete code evidence, ask for the exact file/diff and use SKIP.
|
|
269
|
-
- If you have no new signal to add, reply with exactly: SKIP
|
|
270
|
-
- Talk like a teammate, not an assistant. No pleasantries, no filler.
|
|
271
|
-
- Stay in your lane — only comment on your domain unless something crosses into it.
|
|
272
|
-
- You can name-drop teammates when handing off ("Maya should look at the auth here").
|
|
273
|
-
- If nothing concerns you, use SKIP instead of posting filler.
|
|
274
|
-
- If you have a concern, name it specifically and suggest a direction.
|
|
275
|
-
- No markdown formatting. No bullet lists. No headings. Just a message.
|
|
276
|
-
- Emojis: use one only if it genuinely fits. Default to none.
|
|
277
|
-
- Never start with "Great question", "Of course", "I hope this helps", or similar.
|
|
278
|
-
- Never say "as an AI" or break character.
|
|
279
|
-
${isFinalRound ? '- Final round: be decisive. State your position clearly.' : ''}
|
|
280
|
-
|
|
281
|
-
Write ONLY your message. No name prefix, no labels.`;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Call the AI provider to generate an agent contribution.
|
|
285
|
-
* Uses the persona's model config or falls back to global config.
|
|
286
|
-
* Returns the generated text.
|
|
287
|
-
*/
|
|
288
|
-
async function callAIForContribution(persona, config, contributionPrompt, maxTokensOverride) {
|
|
289
|
-
const soulPrompt = compileSoul(persona);
|
|
290
|
-
const resolved = resolvePersonaAIConfig(persona, config);
|
|
291
|
-
const maxTokens = maxTokensOverride ?? resolved.maxTokens;
|
|
292
|
-
if (resolved.provider === 'anthropic') {
|
|
293
|
-
const apiKey = resolved.envVars['ANTHROPIC_API_KEY']
|
|
294
|
-
?? resolved.envVars['ANTHROPIC_AUTH_TOKEN']
|
|
295
|
-
?? process.env.ANTHROPIC_API_KEY
|
|
296
|
-
?? process.env.ANTHROPIC_AUTH_TOKEN
|
|
297
|
-
?? '';
|
|
298
|
-
const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/messages'), {
|
|
299
|
-
method: 'POST',
|
|
300
|
-
headers: {
|
|
301
|
-
'Content-Type': 'application/json',
|
|
302
|
-
'x-api-key': apiKey,
|
|
303
|
-
'anthropic-version': '2023-06-01',
|
|
304
|
-
},
|
|
305
|
-
body: JSON.stringify({
|
|
306
|
-
model: resolved.model,
|
|
307
|
-
max_tokens: maxTokens,
|
|
308
|
-
system: soulPrompt,
|
|
309
|
-
messages: [{ role: 'user', content: contributionPrompt }],
|
|
310
|
-
}),
|
|
311
|
-
});
|
|
312
|
-
if (!response.ok) {
|
|
313
|
-
const error = await response.text();
|
|
314
|
-
throw new Error(`Anthropic API error: ${response.status} ${error}`);
|
|
315
|
-
}
|
|
316
|
-
const data = await response.json();
|
|
317
|
-
return data.content[0]?.text?.trim() ?? '';
|
|
318
|
-
}
|
|
319
|
-
else if (resolved.provider === 'openai') {
|
|
320
|
-
const apiKey = resolved.envVars['OPENAI_API_KEY'] ?? process.env.OPENAI_API_KEY ?? '';
|
|
321
|
-
const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/chat/completions'), {
|
|
322
|
-
method: 'POST',
|
|
323
|
-
headers: {
|
|
324
|
-
'Content-Type': 'application/json',
|
|
325
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
326
|
-
},
|
|
327
|
-
body: JSON.stringify({
|
|
328
|
-
model: resolved.model,
|
|
329
|
-
max_tokens: maxTokens,
|
|
330
|
-
temperature: resolved.temperature,
|
|
331
|
-
messages: [
|
|
332
|
-
{ role: 'system', content: soulPrompt },
|
|
333
|
-
{ role: 'user', content: contributionPrompt },
|
|
334
|
-
],
|
|
335
|
-
}),
|
|
336
|
-
});
|
|
337
|
-
if (!response.ok) {
|
|
338
|
-
const error = await response.text();
|
|
339
|
-
throw new Error(`OpenAI API error: ${response.status} ${error}`);
|
|
340
|
-
}
|
|
341
|
-
const data = await response.json();
|
|
342
|
-
return data.choices[0]?.message?.content?.trim() ?? '';
|
|
343
|
-
}
|
|
344
|
-
return `[${persona.name}: No AI provider configured]`;
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* Returns Anthropic tool definitions for board operations.
|
|
348
|
-
*/
|
|
349
|
-
function buildBoardTools() {
|
|
350
|
-
const columnEnum = ["Draft", "Ready", "In Progress", "Review", "Done"];
|
|
351
|
-
return [
|
|
352
|
-
{
|
|
353
|
-
name: "open_github_issue",
|
|
354
|
-
description: "Create a new GitHub issue on the project board.",
|
|
355
|
-
input_schema: {
|
|
356
|
-
type: "object",
|
|
357
|
-
properties: {
|
|
358
|
-
title: { type: "string", description: "Short, descriptive issue title." },
|
|
359
|
-
body: { type: "string", description: "Detailed issue description in Markdown." },
|
|
360
|
-
column: { type: "string", enum: columnEnum, description: "Board column to place the issue in. Defaults to 'Ready'." },
|
|
361
|
-
},
|
|
362
|
-
required: ["title", "body"],
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
name: "list_issues",
|
|
367
|
-
description: "List issues on the project board, optionally filtered by column.",
|
|
368
|
-
input_schema: {
|
|
369
|
-
type: "object",
|
|
370
|
-
properties: {
|
|
371
|
-
column: { type: "string", enum: columnEnum, description: "Filter by column. Omit to list all issues." },
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
},
|
|
375
|
-
{
|
|
376
|
-
name: "move_issue",
|
|
377
|
-
description: "Move a GitHub issue to a different column on the board.",
|
|
378
|
-
input_schema: {
|
|
379
|
-
type: "object",
|
|
380
|
-
properties: {
|
|
381
|
-
issue_number: { type: "number", description: "The GitHub issue number." },
|
|
382
|
-
column: { type: "string", enum: columnEnum, description: "Target column." },
|
|
383
|
-
},
|
|
384
|
-
required: ["issue_number", "column"],
|
|
385
|
-
},
|
|
386
|
-
},
|
|
387
|
-
{
|
|
388
|
-
name: "comment_on_issue",
|
|
389
|
-
description: "Add a comment to an existing GitHub issue.",
|
|
390
|
-
input_schema: {
|
|
391
|
-
type: "object",
|
|
392
|
-
properties: {
|
|
393
|
-
issue_number: { type: "number", description: "The GitHub issue number." },
|
|
394
|
-
body: { type: "string", description: "Comment text in Markdown." },
|
|
395
|
-
},
|
|
396
|
-
required: ["issue_number", "body"],
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
{
|
|
400
|
-
name: "close_issue",
|
|
401
|
-
description: "Close a GitHub issue.",
|
|
402
|
-
input_schema: {
|
|
403
|
-
type: "object",
|
|
404
|
-
properties: {
|
|
405
|
-
issue_number: { type: "number", description: "The GitHub issue number." },
|
|
406
|
-
},
|
|
407
|
-
required: ["issue_number"],
|
|
408
|
-
},
|
|
409
|
-
},
|
|
410
|
-
];
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Execute a single board tool call and return a human-readable result string.
|
|
414
|
-
*/
|
|
415
|
-
async function executeBoardTool(name, input, boardConfig, projectPath) {
|
|
416
|
-
const provider = createBoardProvider(boardConfig, projectPath);
|
|
417
|
-
switch (name) {
|
|
418
|
-
case "open_github_issue": {
|
|
419
|
-
const issue = await provider.createIssue({
|
|
420
|
-
title: String(input.title ?? ''),
|
|
421
|
-
body: String(input.body ?? ''),
|
|
422
|
-
column: input.column ?? 'Ready',
|
|
423
|
-
});
|
|
424
|
-
return JSON.stringify({ number: issue.number, url: issue.url, title: issue.title });
|
|
425
|
-
}
|
|
426
|
-
case "list_issues": {
|
|
427
|
-
const issues = input.column
|
|
428
|
-
? await provider.getIssuesByColumn(input.column)
|
|
429
|
-
: await provider.getAllIssues();
|
|
430
|
-
return JSON.stringify(issues.map(i => ({ number: i.number, title: i.title, column: i.column, url: i.url })));
|
|
431
|
-
}
|
|
432
|
-
case "move_issue": {
|
|
433
|
-
await provider.moveIssue(Number(input.issue_number), input.column);
|
|
434
|
-
return `Issue #${input.issue_number} moved to ${String(input.column)}.`;
|
|
435
|
-
}
|
|
436
|
-
case "comment_on_issue": {
|
|
437
|
-
await provider.commentOnIssue(Number(input.issue_number), String(input.body ?? ''));
|
|
438
|
-
return `Comment added to issue #${input.issue_number}.`;
|
|
439
|
-
}
|
|
440
|
-
case "close_issue": {
|
|
441
|
-
await provider.closeIssue(Number(input.issue_number));
|
|
442
|
-
return `Issue #${input.issue_number} closed.`;
|
|
443
|
-
}
|
|
444
|
-
default:
|
|
445
|
-
return `Unknown tool: ${name}`;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Agentic loop for Anthropic with tool use.
|
|
450
|
-
* Calls the AI, executes any tool_use blocks, and loops until a final text reply is produced.
|
|
451
|
-
*/
|
|
452
|
-
async function callAIWithTools(persona, config, prompt, tools, boardConfig, projectPath) {
|
|
453
|
-
const soulPrompt = compileSoul(persona);
|
|
454
|
-
const resolved = resolvePersonaAIConfig(persona, config);
|
|
455
|
-
const apiKey = resolved.envVars['ANTHROPIC_API_KEY']
|
|
456
|
-
?? resolved.envVars['ANTHROPIC_AUTH_TOKEN']
|
|
457
|
-
?? process.env.ANTHROPIC_API_KEY
|
|
458
|
-
?? process.env.ANTHROPIC_AUTH_TOKEN
|
|
459
|
-
?? '';
|
|
460
|
-
const messages = [{ role: 'user', content: prompt }];
|
|
461
|
-
const MAX_TOOL_ITERATIONS = 3;
|
|
462
|
-
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
|
463
|
-
const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/messages'), {
|
|
464
|
-
method: 'POST',
|
|
465
|
-
headers: {
|
|
466
|
-
'Content-Type': 'application/json',
|
|
467
|
-
'x-api-key': apiKey,
|
|
468
|
-
'anthropic-version': '2023-06-01',
|
|
469
|
-
},
|
|
470
|
-
body: JSON.stringify({
|
|
471
|
-
model: resolved.model,
|
|
472
|
-
max_tokens: 1024,
|
|
473
|
-
system: soulPrompt,
|
|
474
|
-
tools,
|
|
475
|
-
messages,
|
|
476
|
-
}),
|
|
477
|
-
});
|
|
478
|
-
if (!response.ok) {
|
|
479
|
-
const error = await response.text();
|
|
480
|
-
throw new Error(`Anthropic API error: ${response.status} ${error}`);
|
|
481
|
-
}
|
|
482
|
-
const data = await response.json();
|
|
483
|
-
if (data.stop_reason !== 'tool_use') {
|
|
484
|
-
// Final reply — extract text
|
|
485
|
-
const textBlock = data.content.find(b => b.type === 'text');
|
|
486
|
-
return textBlock?.text?.trim() ?? '';
|
|
487
|
-
}
|
|
488
|
-
// Execute all tool_use blocks
|
|
489
|
-
const toolUseBlocks = data.content.filter(b => b.type === 'tool_use');
|
|
490
|
-
const toolResults = [];
|
|
491
|
-
for (const block of toolUseBlocks) {
|
|
492
|
-
let result;
|
|
493
|
-
try {
|
|
494
|
-
result = await executeBoardTool(block.name, block.input, boardConfig, projectPath);
|
|
495
|
-
}
|
|
496
|
-
catch (err) {
|
|
497
|
-
result = `Error: ${String(err)}`;
|
|
498
|
-
}
|
|
499
|
-
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result });
|
|
500
|
-
}
|
|
501
|
-
// Append assistant turn and tool results to message history
|
|
502
|
-
messages.push({ role: 'assistant', content: data.content });
|
|
503
|
-
messages.push({ role: 'user', content: toolResults });
|
|
504
|
-
}
|
|
505
|
-
return `[${persona.name}: tool loop exceeded max iterations]`;
|
|
506
|
-
}
|
|
507
|
-
const CANNED_PHRASE_PREFIXES = [
|
|
508
|
-
/^great question[,.! ]*/i,
|
|
509
|
-
/^of course[,.! ]*/i,
|
|
510
|
-
/^certainly[,.! ]*/i,
|
|
511
|
-
/^you['’]re absolutely right[,.! ]*/i,
|
|
512
|
-
/^i hope this helps[,.! ]*/i,
|
|
513
|
-
];
|
|
514
|
-
function isSkipMessage(text) {
|
|
515
|
-
return text.trim().toUpperCase() === 'SKIP';
|
|
516
|
-
}
|
|
517
|
-
function normalizeForComparison(text) {
|
|
518
|
-
return text
|
|
519
|
-
.toLowerCase()
|
|
520
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
521
|
-
.replace(/\s+/g, ' ')
|
|
522
|
-
.trim();
|
|
523
|
-
}
|
|
524
|
-
function formatThreadHistory(messages) {
|
|
525
|
-
return messages
|
|
526
|
-
.map((message) => {
|
|
527
|
-
const body = message.text.replace(/\s+/g, ' ').trim();
|
|
528
|
-
if (!body)
|
|
529
|
-
return '';
|
|
530
|
-
const speaker = message.username?.trim() || 'Teammate';
|
|
531
|
-
return `${speaker}: ${body}`;
|
|
532
|
-
})
|
|
533
|
-
.filter(Boolean)
|
|
534
|
-
.join('\n');
|
|
535
|
-
}
|
|
536
|
-
function countThreadReplies(messages) {
|
|
537
|
-
return Math.max(0, messages.length - 1);
|
|
538
|
-
}
|
|
539
|
-
function chooseRoundContributors(personas, maxCount) {
|
|
540
|
-
if (maxCount <= 0)
|
|
541
|
-
return [];
|
|
542
|
-
const lead = findCarlos(personas);
|
|
543
|
-
if (!lead)
|
|
544
|
-
return personas.slice(0, maxCount);
|
|
545
|
-
const nonLead = personas.filter((persona) => persona.id !== lead.id);
|
|
546
|
-
const candidates = nonLead.length >= 2 ? nonLead : personas;
|
|
547
|
-
return candidates.slice(0, maxCount);
|
|
548
|
-
}
|
|
549
|
-
function dedupeRepeatedSentences(text) {
|
|
550
|
-
const parts = text
|
|
551
|
-
.split(/(?<=[.!?])\s+/)
|
|
552
|
-
.map((part) => part.trim())
|
|
553
|
-
.filter(Boolean);
|
|
554
|
-
if (parts.length <= 1)
|
|
555
|
-
return text;
|
|
556
|
-
const unique = [];
|
|
557
|
-
const seen = new Set();
|
|
558
|
-
for (const part of parts) {
|
|
559
|
-
const normalized = normalizeForComparison(part);
|
|
560
|
-
if (!normalized || seen.has(normalized))
|
|
561
|
-
continue;
|
|
562
|
-
seen.add(normalized);
|
|
563
|
-
unique.push(part);
|
|
564
|
-
}
|
|
565
|
-
return unique.join(' ');
|
|
566
|
-
}
|
|
567
|
-
function limitEmojiCount(text, maxEmojis) {
|
|
568
|
-
let seen = 0;
|
|
569
|
-
return text.replace(/[\p{Extended_Pictographic}]/gu, (m) => {
|
|
570
|
-
seen += 1;
|
|
571
|
-
return seen <= maxEmojis ? m : '';
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
function isFacialEmoji(char) {
|
|
575
|
-
return /[\u{1F600}-\u{1F64F}\u{1F910}-\u{1F92F}\u{1F970}-\u{1F97A}]/u.test(char);
|
|
576
|
-
}
|
|
577
|
-
function applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji) {
|
|
578
|
-
if (!allowEmoji) {
|
|
579
|
-
return text.replace(/[\p{Extended_Pictographic}]/gu, '');
|
|
580
|
-
}
|
|
581
|
-
const emojis = Array.from(text.matchAll(/[\p{Extended_Pictographic}]/gu)).map((m) => m[0]);
|
|
582
|
-
if (emojis.length === 0)
|
|
583
|
-
return text;
|
|
584
|
-
const chosenFacial = emojis.find((e) => isFacialEmoji(e));
|
|
585
|
-
const chosen = chosenFacial ?? (allowNonFacialEmoji ? emojis[0] : null);
|
|
586
|
-
if (!chosen) {
|
|
587
|
-
return text.replace(/[\p{Extended_Pictographic}]/gu, '');
|
|
588
|
-
}
|
|
589
|
-
let kept = false;
|
|
590
|
-
return text.replace(/[\p{Extended_Pictographic}]/gu, (e) => {
|
|
591
|
-
if (!kept && e === chosen) {
|
|
592
|
-
kept = true;
|
|
593
|
-
return e;
|
|
594
|
-
}
|
|
595
|
-
return '';
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
function trimToSentences(text, maxSentences) {
|
|
599
|
-
const parts = text
|
|
600
|
-
.split(/(?<=[.!?])\s+/)
|
|
601
|
-
.map((s) => s.trim())
|
|
602
|
-
.filter(Boolean);
|
|
603
|
-
if (parts.length <= maxSentences)
|
|
604
|
-
return text.trim();
|
|
605
|
-
return parts.slice(0, maxSentences).join(' ').trim();
|
|
606
|
-
}
|
|
607
|
-
export function humanizeSlackReply(raw, options = {}) {
|
|
608
|
-
const { allowEmoji = true, allowNonFacialEmoji = true, maxSentences = MAX_HUMANIZED_SENTENCES, } = options;
|
|
609
|
-
let text = raw.trim();
|
|
610
|
-
if (!text)
|
|
611
|
-
return text;
|
|
612
|
-
if (isSkipMessage(text))
|
|
613
|
-
return 'SKIP';
|
|
614
|
-
// Remove markdown formatting artifacts that look templated in chat.
|
|
615
|
-
text = text
|
|
616
|
-
.replace(/^#{1,6}\s+/gm, '')
|
|
617
|
-
.replace(/^\s*[-*]\s+/gm, '')
|
|
618
|
-
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
619
|
-
.replace(/\s+/g, ' ')
|
|
620
|
-
.trim();
|
|
621
|
-
// Strip common assistant-y openers.
|
|
622
|
-
for (const pattern of CANNED_PHRASE_PREFIXES) {
|
|
623
|
-
text = text.replace(pattern, '').trim();
|
|
624
|
-
}
|
|
625
|
-
text = dedupeRepeatedSentences(text);
|
|
626
|
-
text = applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji);
|
|
627
|
-
text = limitEmojiCount(text, 1);
|
|
628
|
-
text = trimToSentences(text, maxSentences);
|
|
629
|
-
if (text.length > MAX_HUMANIZED_CHARS) {
|
|
630
|
-
text = `${text.slice(0, MAX_HUMANIZED_CHARS - 3).trimEnd()}...`;
|
|
631
|
-
}
|
|
632
|
-
return text;
|
|
633
|
-
}
|
|
634
|
-
function buildCurrentCliInvocation(args) {
|
|
635
|
-
const cliEntry = process.argv[1];
|
|
636
|
-
if (!cliEntry)
|
|
637
|
-
return null;
|
|
638
|
-
return [...process.execArgv, cliEntry, ...args];
|
|
639
|
-
}
|
|
640
|
-
function formatCommandForLog(bin, args) {
|
|
641
|
-
return [bin, ...args].map((part) => JSON.stringify(part)).join(' ');
|
|
642
|
-
}
|
|
643
|
-
export class DeliberationEngine {
|
|
644
|
-
_slackClient;
|
|
645
|
-
_config;
|
|
646
|
-
_humanResumeTimers = new Map();
|
|
647
|
-
_emojiCadenceCounter = new Map();
|
|
648
|
-
constructor(slackClient, config) {
|
|
649
|
-
this._slackClient = slackClient;
|
|
650
|
-
this._config = config;
|
|
651
|
-
}
|
|
652
|
-
_resolveReplyProjectPath(channel, threadTs) {
|
|
653
|
-
const repos = getRepositories();
|
|
654
|
-
const activeDiscussions = repos.slackDiscussion.getActive('');
|
|
655
|
-
const discussion = activeDiscussions.find((d) => d.channelId === channel && d.threadTs === threadTs);
|
|
656
|
-
if (discussion?.projectPath) {
|
|
657
|
-
return discussion.projectPath;
|
|
658
|
-
}
|
|
659
|
-
const projects = repos.projectRegistry.getAll();
|
|
660
|
-
const channelProject = projects.find((p) => p.slackChannelId === channel);
|
|
661
|
-
if (channelProject?.path) {
|
|
662
|
-
return channelProject.path;
|
|
663
|
-
}
|
|
664
|
-
return projects.length === 1 ? projects[0].path : null;
|
|
665
|
-
}
|
|
666
|
-
_resolveBoardConfig(projectPath) {
|
|
667
|
-
try {
|
|
668
|
-
const config = loadConfig(projectPath);
|
|
669
|
-
const boardConfig = config.boardProvider;
|
|
670
|
-
if (boardConfig?.enabled && typeof boardConfig.projectNumber === 'number') {
|
|
671
|
-
return boardConfig;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
catch {
|
|
675
|
-
// Ignore config loading failures and treat as board-not-configured.
|
|
676
|
-
}
|
|
677
|
-
return null;
|
|
678
|
-
}
|
|
679
|
-
_humanizeForPost(channel, threadTs, persona, raw) {
|
|
680
|
-
const key = `${channel}:${threadTs}:${persona.id}`;
|
|
681
|
-
const count = (this._emojiCadenceCounter.get(key) ?? 0) + 1;
|
|
682
|
-
this._emojiCadenceCounter.set(key, count);
|
|
683
|
-
// Human cadence:
|
|
684
|
-
// - emoji roughly every 3rd message by same persona in same thread
|
|
685
|
-
// - non-facial emoji much rarer (roughly every 9th message)
|
|
686
|
-
const allowEmoji = count % 3 === 0;
|
|
687
|
-
const allowNonFacialEmoji = count % 9 === 0;
|
|
688
|
-
return humanizeSlackReply(raw, { allowEmoji, allowNonFacialEmoji, maxSentences: 2 });
|
|
689
|
-
}
|
|
690
|
-
/**
|
|
691
|
-
* Start a new discussion thread for a trigger event.
|
|
692
|
-
* Posts the opening message and kicks off the first round of contributions.
|
|
693
|
-
*/
|
|
694
|
-
async startDiscussion(trigger) {
|
|
695
|
-
const key = discussionStartKey(trigger);
|
|
696
|
-
const existingInFlight = inFlightDiscussionStarts.get(key);
|
|
697
|
-
if (existingInFlight) {
|
|
698
|
-
return existingInFlight;
|
|
699
|
-
}
|
|
700
|
-
const startPromise = this._startDiscussionInternal(trigger);
|
|
701
|
-
inFlightDiscussionStarts.set(key, startPromise);
|
|
702
|
-
try {
|
|
703
|
-
return await startPromise;
|
|
704
|
-
}
|
|
705
|
-
finally {
|
|
706
|
-
if (inFlightDiscussionStarts.get(key) === startPromise) {
|
|
707
|
-
inFlightDiscussionStarts.delete(key);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
async _startDiscussionInternal(trigger) {
|
|
712
|
-
const repos = getRepositories();
|
|
713
|
-
const latest = repos
|
|
714
|
-
.slackDiscussion
|
|
715
|
-
.getLatestByTrigger(trigger.projectPath, trigger.type, trigger.ref);
|
|
716
|
-
if (latest) {
|
|
717
|
-
if (latest.status === 'active') {
|
|
718
|
-
return latest;
|
|
719
|
-
}
|
|
720
|
-
if (Date.now() - latest.updatedAt < DISCUSSION_REPLAY_GUARD_MS) {
|
|
721
|
-
return latest;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
const personas = repos.agentPersona.getActive();
|
|
725
|
-
const participants = getParticipatingPersonas(trigger.type, personas);
|
|
726
|
-
// Resolve project channel for prd_kickoff
|
|
727
|
-
const resolvedTrigger = { ...trigger };
|
|
728
|
-
if (trigger.type === 'prd_kickoff' && !trigger.channelId) {
|
|
729
|
-
const projects = repos.projectRegistry.getAll();
|
|
730
|
-
const project = projects.find(p => p.path === trigger.projectPath);
|
|
731
|
-
if (project?.slackChannelId) {
|
|
732
|
-
resolvedTrigger.channelId = project.slackChannelId;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
if (resolvedTrigger.type === 'pr_review' && !hasConcreteCodeContext(resolvedTrigger.context)) {
|
|
736
|
-
const diffExcerpt = loadPrDiffExcerpt(resolvedTrigger.projectPath, resolvedTrigger.ref);
|
|
737
|
-
if (diffExcerpt) {
|
|
738
|
-
resolvedTrigger.context = `${resolvedTrigger.context}\n\n${diffExcerpt}`.slice(0, 5000);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
const channel = getChannelForTrigger(resolvedTrigger, this._config);
|
|
742
|
-
if (!channel) {
|
|
743
|
-
throw new Error(`No Slack channel configured for trigger type: ${trigger.type}`);
|
|
744
|
-
}
|
|
745
|
-
// Find the dev persona to open the thread
|
|
746
|
-
const devPersona = findDev(participants) ?? participants[0];
|
|
747
|
-
if (!devPersona) {
|
|
748
|
-
throw new Error('No active agent personas found');
|
|
749
|
-
}
|
|
750
|
-
// Post opening message to start the thread
|
|
751
|
-
const openingText = trigger.openingMessage ?? buildOpeningMessage(resolvedTrigger);
|
|
752
|
-
const openingMsg = await this._slackClient.postAsAgent(channel, openingText, devPersona);
|
|
753
|
-
await sleep(humanDelay());
|
|
754
|
-
// Create discussion record
|
|
755
|
-
const discussion = repos.slackDiscussion.create({
|
|
756
|
-
projectPath: trigger.projectPath,
|
|
757
|
-
triggerType: trigger.type,
|
|
758
|
-
triggerRef: trigger.ref,
|
|
759
|
-
channelId: channel,
|
|
760
|
-
threadTs: openingMsg.ts,
|
|
761
|
-
status: 'active',
|
|
762
|
-
round: 1,
|
|
763
|
-
participants: [devPersona.id],
|
|
764
|
-
consensusResult: null,
|
|
765
|
-
});
|
|
766
|
-
// Run first round of contributions (excluding Dev who already posted)
|
|
767
|
-
const reviewers = participants.filter((p) => p.id !== devPersona.id);
|
|
768
|
-
await this._runContributionRound(discussion.id, reviewers, resolvedTrigger, openingText);
|
|
769
|
-
// Check consensus after first round
|
|
770
|
-
await this._evaluateConsensus(discussion.id, resolvedTrigger);
|
|
771
|
-
return repos.slackDiscussion.getById(discussion.id);
|
|
772
|
-
}
|
|
773
|
-
/**
|
|
774
|
-
* Have a specific agent contribute to an existing discussion.
|
|
775
|
-
*/
|
|
776
|
-
async contributeAsAgent(discussionId, persona) {
|
|
777
|
-
const repos = getRepositories();
|
|
778
|
-
const discussion = repos.slackDiscussion.getById(discussionId);
|
|
779
|
-
if (!discussion || discussion.status !== 'active')
|
|
780
|
-
return;
|
|
781
|
-
// Get thread history for context
|
|
782
|
-
const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
|
|
783
|
-
const historyText = formatThreadHistory(history);
|
|
784
|
-
const historySet = new Set(history.map((m) => normalizeForComparison(m.text)).filter(Boolean));
|
|
785
|
-
// Rebuild trigger context from discussion record
|
|
786
|
-
const trigger = {
|
|
787
|
-
type: discussion.triggerType,
|
|
788
|
-
projectPath: discussion.projectPath,
|
|
789
|
-
ref: discussion.triggerRef,
|
|
790
|
-
context: historyText,
|
|
791
|
-
};
|
|
792
|
-
const contributionPrompt = buildContributionPrompt(persona, trigger, historyText, discussion.round);
|
|
793
|
-
let message;
|
|
794
|
-
try {
|
|
795
|
-
message = await callAIForContribution(persona, this._config, contributionPrompt);
|
|
796
|
-
}
|
|
797
|
-
catch (err) {
|
|
798
|
-
console.error(`[deliberation] callAIForContribution failed for ${persona.name}:`, err);
|
|
799
|
-
message = `[Contribution from ${persona.name} unavailable — AI provider not configured]`;
|
|
800
|
-
}
|
|
801
|
-
if (message) {
|
|
802
|
-
const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
|
|
803
|
-
if (isSkipMessage(finalMessage))
|
|
804
|
-
return;
|
|
805
|
-
const normalized = normalizeForComparison(finalMessage);
|
|
806
|
-
if (!normalized || historySet.has(normalized))
|
|
807
|
-
return;
|
|
808
|
-
await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
|
|
809
|
-
repos.slackDiscussion.addParticipant(discussionId, persona.id);
|
|
810
|
-
await sleep(humanDelay());
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Handle a human message posted in a discussion thread.
|
|
815
|
-
* Pauses agent contributions; lead summarizes after silence.
|
|
816
|
-
*/
|
|
817
|
-
async handleHumanMessage(channel, threadTs, _message, _userId) {
|
|
818
|
-
// Find the discussion by threadTs
|
|
819
|
-
const repos = getRepositories();
|
|
820
|
-
const activeDiscussions = repos.slackDiscussion.getActive('');
|
|
821
|
-
const discussion = activeDiscussions.find(d => d.channelId === channel && d.threadTs === threadTs);
|
|
822
|
-
if (!discussion)
|
|
823
|
-
return;
|
|
824
|
-
const existingTimer = this._humanResumeTimers.get(discussion.id);
|
|
825
|
-
if (existingTimer) {
|
|
826
|
-
clearTimeout(existingTimer);
|
|
827
|
-
}
|
|
828
|
-
// Human is involved — debounce for a short pause before the lead summarizes.
|
|
829
|
-
const timer = setTimeout(() => {
|
|
830
|
-
void (async () => {
|
|
831
|
-
const innerRepos = getRepositories();
|
|
832
|
-
const personas = innerRepos.agentPersona.getActive();
|
|
833
|
-
const carlos = findCarlos(personas) ?? personas[0];
|
|
834
|
-
if (!carlos)
|
|
835
|
-
return;
|
|
836
|
-
const updated = innerRepos.slackDiscussion.getById(discussion.id);
|
|
837
|
-
if (!updated || updated.status !== 'active')
|
|
838
|
-
return;
|
|
839
|
-
await this._slackClient.postAsAgent(channel, "Ok, picking this back up. Let me see where we landed.", carlos, threadTs);
|
|
840
|
-
await sleep(humanDelay());
|
|
841
|
-
await this._evaluateConsensus(discussion.id, {
|
|
842
|
-
type: discussion.triggerType,
|
|
843
|
-
projectPath: discussion.projectPath,
|
|
844
|
-
ref: discussion.triggerRef,
|
|
845
|
-
context: '',
|
|
846
|
-
});
|
|
847
|
-
})().finally(() => {
|
|
848
|
-
this._humanResumeTimers.delete(discussion.id);
|
|
849
|
-
});
|
|
850
|
-
}, DISCUSSION_RESUME_DELAY_MS);
|
|
851
|
-
this._humanResumeTimers.set(discussion.id, timer);
|
|
852
|
-
}
|
|
853
|
-
/**
|
|
854
|
-
* Run a round of contributions from the given personas.
|
|
855
|
-
*/
|
|
856
|
-
async _runContributionRound(discussionId, personas, trigger, currentContext) {
|
|
857
|
-
const repos = getRepositories();
|
|
858
|
-
const discussion = repos.slackDiscussion.getById(discussionId);
|
|
859
|
-
if (!discussion)
|
|
860
|
-
return;
|
|
861
|
-
// Get current thread history
|
|
862
|
-
let history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
|
|
863
|
-
let historyText = formatThreadHistory(history) || currentContext;
|
|
864
|
-
const seenMessages = new Set(history.map((message) => normalizeForComparison(message.text)).filter(Boolean));
|
|
865
|
-
const repliesUsed = countThreadReplies(history);
|
|
866
|
-
const reviewerBudget = Math.max(0, MAX_AGENT_THREAD_REPLIES - repliesUsed - 1);
|
|
867
|
-
if (reviewerBudget <= 0)
|
|
868
|
-
return;
|
|
869
|
-
const contributors = chooseRoundContributors(personas, Math.min(MAX_CONTRIBUTIONS_PER_ROUND, reviewerBudget));
|
|
870
|
-
let posted = 0;
|
|
871
|
-
for (const persona of contributors) {
|
|
872
|
-
if (posted >= reviewerBudget)
|
|
873
|
-
break;
|
|
874
|
-
const updatedDiscussion = repos.slackDiscussion.getById(discussionId);
|
|
875
|
-
if (!updatedDiscussion || updatedDiscussion.status !== 'active')
|
|
876
|
-
break;
|
|
877
|
-
const contributionPrompt = buildContributionPrompt(persona, trigger, historyText, updatedDiscussion.round);
|
|
878
|
-
let message;
|
|
879
|
-
try {
|
|
880
|
-
message = await callAIForContribution(persona, this._config, contributionPrompt);
|
|
881
|
-
}
|
|
882
|
-
catch (_err) {
|
|
883
|
-
message = '';
|
|
884
|
-
}
|
|
885
|
-
if (!message || isSkipMessage(message))
|
|
886
|
-
continue;
|
|
887
|
-
const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
|
|
888
|
-
if (!finalMessage || isSkipMessage(finalMessage))
|
|
889
|
-
continue;
|
|
890
|
-
const normalized = normalizeForComparison(finalMessage);
|
|
891
|
-
if (!normalized || seenMessages.has(normalized))
|
|
892
|
-
continue;
|
|
893
|
-
await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
|
|
894
|
-
repos.slackDiscussion.addParticipant(discussionId, persona.id);
|
|
895
|
-
seenMessages.add(normalized);
|
|
896
|
-
posted += 1;
|
|
897
|
-
history = [
|
|
898
|
-
...history,
|
|
899
|
-
{
|
|
900
|
-
ts: `${Date.now()}-${persona.id}`,
|
|
901
|
-
channel: discussion.channelId,
|
|
902
|
-
text: finalMessage,
|
|
903
|
-
username: persona.name,
|
|
904
|
-
},
|
|
905
|
-
];
|
|
906
|
-
historyText = formatThreadHistory(history) || historyText;
|
|
907
|
-
await sleep(humanDelay());
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
/**
|
|
911
|
-
* Evaluate whether consensus has been reached.
|
|
912
|
-
* Lead agent (Carlos) decides: approve, request changes, or escalate.
|
|
913
|
-
* Uses an iterative loop for multi-round handling (no recursion).
|
|
914
|
-
*/
|
|
915
|
-
async _evaluateConsensus(discussionId, trigger) {
|
|
916
|
-
const repos = getRepositories();
|
|
917
|
-
// Re-check state each round; stop when consensus/blocked or discussion disappears.
|
|
918
|
-
while (true) {
|
|
919
|
-
const discussion = repos.slackDiscussion.getById(discussionId);
|
|
920
|
-
if (!discussion || discussion.status !== 'active')
|
|
921
|
-
return;
|
|
922
|
-
const personas = repos.agentPersona.getActive();
|
|
923
|
-
const carlos = findCarlos(personas);
|
|
924
|
-
if (!carlos) {
|
|
925
|
-
repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'approved');
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
// Get thread history and let Carlos evaluate
|
|
929
|
-
const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 20);
|
|
930
|
-
const historyText = formatThreadHistory(history);
|
|
931
|
-
const repliesUsed = countThreadReplies(history);
|
|
932
|
-
const repliesLeft = Math.max(0, MAX_AGENT_THREAD_REPLIES - repliesUsed);
|
|
933
|
-
if (repliesLeft <= 0) {
|
|
934
|
-
repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
937
|
-
const consensusPrompt = `You are ${carlos.name}, ${carlos.role}. You're wrapping up a team discussion.
|
|
938
|
-
|
|
939
|
-
Thread:
|
|
940
|
-
${historyText || '(No thread history available)'}
|
|
941
|
-
|
|
942
|
-
Round: ${discussion.round}/${MAX_ROUNDS}
|
|
943
|
-
|
|
944
|
-
Make the call. Are we done, do we need one more pass, or does a human need to weigh in?
|
|
945
|
-
- Keep it brief and decisive. No recap of the whole thread.
|
|
946
|
-
- If you approve, do not restate prior arguments.
|
|
947
|
-
|
|
948
|
-
Respond with EXACTLY one of these formats (include the prefix):
|
|
949
|
-
- APPROVE: [one short closing message in your voice — e.g., "Clean. Let's ship it."]
|
|
950
|
-
- CHANGES: [what specifically still needs work — be concrete, not vague]
|
|
951
|
-
- HUMAN: [why this needs a human decision — be specific about what's ambiguous]
|
|
952
|
-
|
|
953
|
-
Write the prefix and your message. Nothing else.`;
|
|
954
|
-
let decision;
|
|
955
|
-
try {
|
|
956
|
-
decision = await callAIForContribution(carlos, this._config, consensusPrompt);
|
|
957
|
-
}
|
|
958
|
-
catch (_err) {
|
|
959
|
-
decision = 'HUMAN: AI evaluation failed — needs manual review';
|
|
960
|
-
}
|
|
961
|
-
if (decision.startsWith('APPROVE')) {
|
|
962
|
-
const message = humanizeSlackReply(decision.replace(/^APPROVE:\s*/, '').trim() || 'Clean. Ship it.', { allowEmoji: false, maxSentences: 1 });
|
|
963
|
-
if (!isSkipMessage(message)) {
|
|
964
|
-
await this._slackClient.postAsAgent(discussion.channelId, message, carlos, discussion.threadTs);
|
|
965
|
-
}
|
|
966
|
-
repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'approved');
|
|
967
|
-
if (trigger.type === 'code_watch') {
|
|
968
|
-
await this.triggerIssueOpener(discussionId, trigger)
|
|
969
|
-
.catch((e) => console.warn('Issue opener failed:', String(e)));
|
|
970
|
-
}
|
|
971
|
-
return;
|
|
972
|
-
}
|
|
973
|
-
if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS && repliesLeft >= 3) {
|
|
974
|
-
const changes = decision.replace(/^CHANGES:\s*/, '').trim();
|
|
975
|
-
const changesMessage = humanizeSlackReply(changes || 'Need one more pass on a couple items.', {
|
|
976
|
-
allowEmoji: false,
|
|
977
|
-
maxSentences: 1,
|
|
978
|
-
});
|
|
979
|
-
if (!isSkipMessage(changesMessage)) {
|
|
980
|
-
await this._slackClient.postAsAgent(discussion.channelId, changesMessage, carlos, discussion.threadTs);
|
|
981
|
-
}
|
|
982
|
-
await sleep(humanDelay());
|
|
983
|
-
// Increment round and start another contribution round, then loop back.
|
|
984
|
-
const nextRound = discussion.round + 1;
|
|
985
|
-
repos.slackDiscussion.updateRound(discussionId, nextRound);
|
|
986
|
-
const participants = getParticipatingPersonas(trigger.type, personas);
|
|
987
|
-
const devPersona = findDev(personas);
|
|
988
|
-
const reviewers = participants.filter((p) => !devPersona || p.id !== devPersona.id);
|
|
989
|
-
await this._runContributionRound(discussionId, reviewers, trigger, changes);
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
if (decision.startsWith('CHANGES')) {
|
|
993
|
-
const changesSummary = decision.replace(/^CHANGES:\s*/, '').trim();
|
|
994
|
-
const summaryMessage = humanizeSlackReply(changesSummary
|
|
995
|
-
? `Need changes before merge: ${changesSummary}`
|
|
996
|
-
: 'Need changes before merge. Please address the thread notes.', { allowEmoji: false, maxSentences: 2 });
|
|
997
|
-
if (!isSkipMessage(summaryMessage)) {
|
|
998
|
-
await this._slackClient.postAsAgent(discussion.channelId, summaryMessage, carlos, discussion.threadTs);
|
|
999
|
-
}
|
|
1000
|
-
repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'changes_requested');
|
|
1001
|
-
if (discussion.triggerType === 'pr_review') {
|
|
1002
|
-
await this.triggerPRRefinement(discussionId, changesSummary, discussion.triggerRef).catch(e => console.warn('PR refinement trigger failed:', e));
|
|
1003
|
-
}
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
// HUMAN or fallback
|
|
1007
|
-
const humanReason = decision.replace(/^HUMAN:\s*/, '').trim();
|
|
1008
|
-
const humanMessage = humanizeSlackReply(humanReason
|
|
1009
|
-
? `Need a human decision: ${humanReason}`
|
|
1010
|
-
: 'Need a human decision on this one.', { allowEmoji: false, maxSentences: 1 });
|
|
1011
|
-
if (!isSkipMessage(humanMessage)) {
|
|
1012
|
-
await this._slackClient.postAsAgent(discussion.channelId, humanMessage, carlos, discussion.threadTs);
|
|
1013
|
-
}
|
|
1014
|
-
repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
/**
|
|
1019
|
-
* Trigger the Night Watch reviewer agent with Slack discussion feedback.
|
|
1020
|
-
* Sets NW_SLACK_FEEDBACK env var with the changes summary.
|
|
1021
|
-
*/
|
|
1022
|
-
async triggerPRRefinement(discussionId, changesSummary, prNumber) {
|
|
1023
|
-
const repos = getRepositories();
|
|
1024
|
-
const discussion = repos.slackDiscussion.getById(discussionId);
|
|
1025
|
-
if (!discussion)
|
|
1026
|
-
return;
|
|
1027
|
-
const personas = repos.agentPersona.getActive();
|
|
1028
|
-
const carlos = findCarlos(personas) ?? personas[0];
|
|
1029
|
-
const actor = carlos?.name ?? 'Night Watch';
|
|
1030
|
-
if (carlos) {
|
|
1031
|
-
await this._slackClient.postAsAgent(discussion.channelId, `Sending PR #${prNumber} back through with the notes.`, carlos, discussion.threadTs);
|
|
1032
|
-
await sleep(humanDelay());
|
|
1033
|
-
}
|
|
1034
|
-
// Set NW_SLACK_FEEDBACK and trigger reviewer
|
|
1035
|
-
const feedback = JSON.stringify({ discussionId, prNumber, changes: changesSummary });
|
|
1036
|
-
const invocationArgs = buildCurrentCliInvocation(['review']);
|
|
1037
|
-
if (!invocationArgs) {
|
|
1038
|
-
console.warn(`[slack][job] triggerPRRefinement reviewer spawn failed via ${actor} pr=${prNumber}: CLI entry path unavailable`);
|
|
1039
|
-
if (carlos) {
|
|
1040
|
-
await this._slackClient.postAsAgent(discussion.channelId, `Can't start the reviewer right now — runtime issue. Will retry.`, carlos, discussion.threadTs);
|
|
1041
|
-
}
|
|
1042
|
-
return;
|
|
1043
|
-
}
|
|
1044
|
-
console.log(`[slack][job] triggerPRRefinement reviewer spawn via ${actor} pr=${prNumber} cmd=${formatCommandForLog(process.execPath, invocationArgs)}`);
|
|
1045
|
-
// Spawn the reviewer as a detached process
|
|
1046
|
-
const { spawn } = await import('child_process');
|
|
1047
|
-
const reviewer = spawn(process.execPath, invocationArgs, {
|
|
1048
|
-
detached: true,
|
|
1049
|
-
stdio: 'ignore',
|
|
1050
|
-
env: { ...process.env, NW_SLACK_FEEDBACK: feedback, NW_TARGET_PR: prNumber },
|
|
1051
|
-
});
|
|
1052
|
-
reviewer.unref();
|
|
1053
|
-
}
|
|
1054
|
-
/**
|
|
1055
|
-
* Reply as a persona in any Slack thread — no formal discussion required.
|
|
1056
|
-
* Used when someone @mentions a persona outside of a Night Watch discussion.
|
|
1057
|
-
*/
|
|
1058
|
-
async replyAsAgent(channel, threadTs, incomingText, persona, projectContext) {
|
|
1059
|
-
let history = [];
|
|
1060
|
-
try {
|
|
1061
|
-
history = await this._slackClient.getChannelHistory(channel, threadTs, 10);
|
|
1062
|
-
}
|
|
1063
|
-
catch {
|
|
1064
|
-
// Ignore — reply with just the incoming text as context
|
|
1065
|
-
}
|
|
1066
|
-
const historyText = formatThreadHistory(history);
|
|
1067
|
-
const historySet = new Set(history.map((m) => normalizeForComparison(m.text)).filter(Boolean));
|
|
1068
|
-
const prompt = `You are ${persona.name}, ${persona.role}.\n` +
|
|
1069
|
-
`Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
|
|
1070
|
-
(projectContext ? `Project context: ${projectContext}\n\n` : '') +
|
|
1071
|
-
(historyText ? `Thread so far:\n${historyText}\n\n` : '') +
|
|
1072
|
-
`Latest message: "${incomingText}"\n\n` +
|
|
1073
|
-
`Respond in your own voice. This is Slack — keep it to 1-2 sentences.\n` +
|
|
1074
|
-
`- Talk like a colleague, not a bot. No "Great question", "Of course", or "I hope this helps".\n` +
|
|
1075
|
-
`- You can tag teammates by name if someone else should weigh in.\n` +
|
|
1076
|
-
`- No markdown formatting, headings, or bullet lists.\n` +
|
|
1077
|
-
`- Emojis: one max, only if it fits naturally. Default to none.\n` +
|
|
1078
|
-
`- If the question is outside your domain, say so briefly and point to the right person.\n` +
|
|
1079
|
-
`- If you disagree, say why in one line. If you agree, keep it short.\n` +
|
|
1080
|
-
`- Base opinions on concrete code evidence from context (file path, symbol, diff, or stack/log detail).\n` +
|
|
1081
|
-
`- If there is no concrete code evidence, ask for the exact file/diff before giving an opinion.\n` +
|
|
1082
|
-
`- You have board tools available. If asked to open, update, or list issues, use them — don't just say you will.\n\n` +
|
|
1083
|
-
`Write only your reply. No name prefix.`;
|
|
1084
|
-
const projectPathForTools = this._resolveReplyProjectPath(channel, threadTs);
|
|
1085
|
-
const boardConfig = projectPathForTools
|
|
1086
|
-
? this._resolveBoardConfig(projectPathForTools)
|
|
1087
|
-
: null;
|
|
1088
|
-
const resolved = resolvePersonaAIConfig(persona, this._config);
|
|
1089
|
-
const useTools = Boolean(projectPathForTools && boardConfig && resolved.provider === 'anthropic');
|
|
1090
|
-
let message;
|
|
1091
|
-
try {
|
|
1092
|
-
if (useTools) {
|
|
1093
|
-
message = await callAIWithTools(persona, this._config, prompt, buildBoardTools(), boardConfig, projectPathForTools);
|
|
1094
|
-
}
|
|
1095
|
-
else {
|
|
1096
|
-
// Allow up to 1024 tokens for ad-hoc replies so agents can write substantive responses
|
|
1097
|
-
message = await callAIForContribution(persona, this._config, prompt, 1024);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
catch (err) {
|
|
1101
|
-
console.error(`[deliberation] reply failed for ${persona.name}:`, err);
|
|
1102
|
-
message = `[Reply from ${persona.name} unavailable — AI provider not configured]`;
|
|
1103
|
-
}
|
|
1104
|
-
if (message) {
|
|
1105
|
-
const finalMessage = this._humanizeForPost(channel, threadTs, persona, message);
|
|
1106
|
-
if (isSkipMessage(finalMessage))
|
|
1107
|
-
return '';
|
|
1108
|
-
const normalized = normalizeForComparison(finalMessage);
|
|
1109
|
-
if (!normalized || historySet.has(normalized))
|
|
1110
|
-
return '';
|
|
1111
|
-
await this._slackClient.postAsAgent(channel, finalMessage, persona, threadTs);
|
|
1112
|
-
return finalMessage;
|
|
1113
|
-
}
|
|
1114
|
-
return '';
|
|
1115
|
-
}
|
|
1116
|
-
/**
|
|
1117
|
-
* Generate and post a proactive message from a persona.
|
|
1118
|
-
* Used by the interaction listener when a channel has been idle.
|
|
1119
|
-
* The persona shares an observation, question, or suggestion based on
|
|
1120
|
-
* project context and roadmap state — in their own voice.
|
|
1121
|
-
*/
|
|
1122
|
-
async postProactiveMessage(channel, persona, projectContext, roadmapContext) {
|
|
1123
|
-
const prompt = `You are ${persona.name}, ${persona.role}.\n` +
|
|
1124
|
-
`Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
|
|
1125
|
-
`You're posting an unprompted message in the team's Slack channel. ` +
|
|
1126
|
-
`The channel has been quiet — you want to share something useful, not just fill silence.\n\n` +
|
|
1127
|
-
(projectContext ? `Project context: ${projectContext}\n\n` : '') +
|
|
1128
|
-
(roadmapContext ? `Roadmap/PRD status:\n${roadmapContext}\n\n` : '') +
|
|
1129
|
-
`Write a SHORT proactive message (1-2 sentences) that does ONE of these:\n` +
|
|
1130
|
-
`- Question a roadmap priority or ask if something should be reordered\n` +
|
|
1131
|
-
`- Flag something you've been thinking about from your domain (security concern, test gap, architectural question, implementation idea)\n` +
|
|
1132
|
-
`- Suggest an improvement or raise a "have we thought about..." question\n` +
|
|
1133
|
-
`- Share a concrete observation about the current state of the project\n` +
|
|
1134
|
-
`- Offer to kick off a task: "I can run a review on X if nobody's on it"\n\n` +
|
|
1135
|
-
`Rules:\n` +
|
|
1136
|
-
`- Stay in your lane. Only bring up things relevant to your expertise.\n` +
|
|
1137
|
-
`- Be specific — name the feature, file, or concern. No vague "we should think about things."\n` +
|
|
1138
|
-
`- Sound like a teammate dropping a thought in chat, not making an announcement.\n` +
|
|
1139
|
-
`- No markdown, headings, bullets. Just a message.\n` +
|
|
1140
|
-
`- No "Great question", "Just checking in", or "Hope everyone is doing well."\n` +
|
|
1141
|
-
`- Emojis: one max, only if natural. Default to none.\n` +
|
|
1142
|
-
`- If you genuinely have nothing useful to say, write exactly: SKIP\n\n` +
|
|
1143
|
-
`Write only your message. No name prefix.`;
|
|
1144
|
-
let message;
|
|
1145
|
-
try {
|
|
1146
|
-
message = await callAIForContribution(persona, this._config, prompt);
|
|
1147
|
-
}
|
|
1148
|
-
catch {
|
|
1149
|
-
return; // Silently skip — proactive messages are optional
|
|
1150
|
-
}
|
|
1151
|
-
if (!message || message.trim().toUpperCase() === 'SKIP') {
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
const dummyTs = `${Date.now()}`;
|
|
1155
|
-
const finalMessage = this._humanizeForPost(channel, dummyTs, persona, message);
|
|
1156
|
-
if (finalMessage) {
|
|
1157
|
-
await this._slackClient.postAsAgent(channel, finalMessage, persona);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
/**
|
|
1161
|
-
* Generate a structured GitHub issue body written by the Dev persona.
|
|
1162
|
-
*/
|
|
1163
|
-
async _generateIssueBody(trigger, devPersona) {
|
|
1164
|
-
const prompt = `You are ${devPersona.name}, ${devPersona.role}.
|
|
1165
|
-
Use the PRD rigor from ~/.claude/skills/prd-creator/SKILL.md:
|
|
1166
|
-
- explicit implementation plan
|
|
1167
|
-
- testable phases
|
|
1168
|
-
- concrete verification steps
|
|
1169
|
-
- no vague filler
|
|
1170
|
-
|
|
1171
|
-
Write a concise GitHub issue body for this code scan finding.
|
|
1172
|
-
Use this structure exactly (GitHub Markdown):
|
|
1173
|
-
|
|
1174
|
-
## Context
|
|
1175
|
-
- Problem: one sentence
|
|
1176
|
-
- Current behavior: one sentence
|
|
1177
|
-
- Risk if ignored: one sentence
|
|
1178
|
-
|
|
1179
|
-
## Proposed Fix
|
|
1180
|
-
- Primary approach
|
|
1181
|
-
- Files likely touched (max 5, include paths when possible)
|
|
1182
|
-
|
|
1183
|
-
## Execution Plan
|
|
1184
|
-
### Phase 1: [name]
|
|
1185
|
-
- [ ] Implementation step
|
|
1186
|
-
- [ ] Tests to add/update
|
|
1187
|
-
|
|
1188
|
-
### Phase 2: [name]
|
|
1189
|
-
- [ ] Implementation step
|
|
1190
|
-
- [ ] Tests to add/update
|
|
1191
|
-
|
|
1192
|
-
## Verification
|
|
1193
|
-
- [ ] Automated: specific tests or commands to run
|
|
1194
|
-
- [ ] Manual: one concrete validation step
|
|
1195
|
-
|
|
1196
|
-
## Done Criteria
|
|
1197
|
-
- [ ] Bug condition is no longer reproducible
|
|
1198
|
-
- [ ] Regression coverage is added
|
|
1199
|
-
- [ ] Error handling/logging is clear and non-silent
|
|
1200
|
-
|
|
1201
|
-
Keep it under ~450 words. No fluff, no greetings, no generic "future work" sections.
|
|
1202
|
-
|
|
1203
|
-
Context:
|
|
1204
|
-
${trigger.context}`;
|
|
1205
|
-
const raw = await callAIForContribution(devPersona, this._config, prompt);
|
|
1206
|
-
return raw.trim();
|
|
1207
|
-
}
|
|
1208
|
-
/**
|
|
1209
|
-
* Have Dev read the actual code and decide whether a scanner finding is worth raising.
|
|
1210
|
-
* Returns Dev's Slack-ready observation, or null if Dev thinks it's not worth posting.
|
|
1211
|
-
*/
|
|
1212
|
-
async analyzeCodeCandidate(fileContext, signalSummary, location) {
|
|
1213
|
-
const repos = getRepositories();
|
|
1214
|
-
const personas = repos.agentPersona.getActive();
|
|
1215
|
-
const devPersona = findDev(personas);
|
|
1216
|
-
if (!devPersona)
|
|
1217
|
-
return null;
|
|
1218
|
-
const prompt = `You are ${devPersona.name}, ${devPersona.role}.\n` +
|
|
1219
|
-
`Your scanner flagged something. Before you bring it up with the team, read the actual code and decide if it's genuinely worth raising.\n\n` +
|
|
1220
|
-
`Signal: ${signalSummary}\n` +
|
|
1221
|
-
`Location: ${location}\n\n` +
|
|
1222
|
-
`Code:\n\`\`\`\n${fileContext.slice(0, 3000)}\n\`\`\`\n\n` +
|
|
1223
|
-
`Is this a real concern? Give your honest take in 1-2 sentences as a Slack message to the team.\n\n` +
|
|
1224
|
-
`Rules:\n` +
|
|
1225
|
-
`- If it's clearly fine (intentional, test code, well-handled, noise) → respond with exactly: SKIP\n` +
|
|
1226
|
-
`- If it's worth flagging, write what you'd drop in Slack in your own voice. Name the specific risk.\n` +
|
|
1227
|
-
`- Sound like a teammate noticing something, not a scanner filing a report.\n` +
|
|
1228
|
-
`- No markdown, no bullet points. No "I noticed" or "The code has".\n` +
|
|
1229
|
-
`- Never start with "Great question", "Of course", or similar.\n\n` +
|
|
1230
|
-
`Write only your message or SKIP.`;
|
|
1231
|
-
try {
|
|
1232
|
-
const result = await callAIForContribution(devPersona, this._config, prompt);
|
|
1233
|
-
if (!result || result.trim().toUpperCase() === 'SKIP')
|
|
1234
|
-
return null;
|
|
1235
|
-
return humanizeSlackReply(result, { allowEmoji: false, maxSentences: 2 });
|
|
1236
|
-
}
|
|
1237
|
-
catch {
|
|
1238
|
-
return null;
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Triage an audit report, file a GitHub issue if warranted, and post a short Slack ping.
|
|
1243
|
-
* No discussion thread — Dev just drops a link in the channel and moves on.
|
|
1244
|
-
*/
|
|
1245
|
-
async handleAuditReport(report, projectName, projectPath, channel) {
|
|
1246
|
-
if (!report || report.trim() === 'NO_ISSUES_FOUND')
|
|
1247
|
-
return;
|
|
1248
|
-
const repos = getRepositories();
|
|
1249
|
-
const personas = repos.agentPersona.getActive();
|
|
1250
|
-
const devPersona = findDev(personas);
|
|
1251
|
-
if (!devPersona)
|
|
1252
|
-
return;
|
|
1253
|
-
// Step 1: Dev triages the report — worth filing? If yes, give a one-liner for Slack.
|
|
1254
|
-
const triagePrompt = `You are ${devPersona.name}, ${devPersona.role}.\n` +
|
|
1255
|
-
`The code auditor just finished scanning ${projectName} and wrote this report:\n\n` +
|
|
1256
|
-
`${report.slice(0, 3000)}\n\n` +
|
|
1257
|
-
`Should this be filed as a GitHub issue for the team to track?\n\n` +
|
|
1258
|
-
`Rules:\n` +
|
|
1259
|
-
`- If the findings are genuinely worth tracking (medium or high severity, real risk) → reply with:\n` +
|
|
1260
|
-
` FILE: [one short sentence you'd drop in Slack — specific about what was found, no filler]\n` +
|
|
1261
|
-
`- If everything is minor, intentional, or noise → reply with exactly: SKIP\n` +
|
|
1262
|
-
`- Be honest. Don't file issues for trivial noise.\n\n` +
|
|
1263
|
-
`Write only FILE: [sentence] or SKIP.`;
|
|
1264
|
-
let triage;
|
|
1265
|
-
try {
|
|
1266
|
-
triage = await callAIForContribution(devPersona, this._config, triagePrompt, 256);
|
|
1267
|
-
}
|
|
1268
|
-
catch {
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
if (!triage || triage.trim().toUpperCase() === 'SKIP' || !/^FILE:/i.test(triage.trim())) {
|
|
1272
|
-
console.log(`[deliberation][audit] Dev skipped filing for ${projectName}`);
|
|
1273
|
-
return;
|
|
1274
|
-
}
|
|
1275
|
-
const slackOneliner = triage.replace(/^FILE:\s*/i, '').trim();
|
|
1276
|
-
if (!slackOneliner)
|
|
1277
|
-
return;
|
|
1278
|
-
// Step 2: Generate a proper GitHub issue body via Dev
|
|
1279
|
-
const fakeTrigger = {
|
|
1280
|
-
type: 'code_watch',
|
|
1281
|
-
projectPath,
|
|
1282
|
-
ref: `audit-${Date.now()}`,
|
|
1283
|
-
context: `Project: ${projectName}\n\nAudit report:\n${report.slice(0, 2000)}`,
|
|
1284
|
-
};
|
|
1285
|
-
const issueTitle = `fix: ${slackOneliner
|
|
1286
|
-
.toLowerCase()
|
|
1287
|
-
.replace(/[.!?]+$/, '')
|
|
1288
|
-
.replace(/^(found|noticed|flagging|caught)\s+/i, '')
|
|
1289
|
-
.slice(0, 80)}`;
|
|
1290
|
-
const issueBody = await this._generateIssueBody(fakeTrigger, devPersona).catch(() => report.slice(0, 1200));
|
|
1291
|
-
// Step 3: Create GitHub issue (if board is configured for this project)
|
|
1292
|
-
const boardConfig = this._resolveBoardConfig(projectPath);
|
|
1293
|
-
let issueUrl = null;
|
|
1294
|
-
if (boardConfig) {
|
|
1295
|
-
try {
|
|
1296
|
-
const provider = createBoardProvider(boardConfig, projectPath);
|
|
1297
|
-
const issue = await provider.createIssue({ title: issueTitle, body: issueBody, column: 'Ready' });
|
|
1298
|
-
issueUrl = issue.url;
|
|
1299
|
-
console.log(`[deliberation][audit] filed issue #${issue.number} for ${projectName}: ${issueUrl}`);
|
|
1300
|
-
}
|
|
1301
|
-
catch (err) {
|
|
1302
|
-
console.warn('[deliberation][audit] failed to create GitHub issue:', err);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
// Step 4: Post brief Slack notification — just a link drop, no thread
|
|
1306
|
-
const slackMsg = issueUrl
|
|
1307
|
-
? `${slackOneliner} → ${issueUrl}`
|
|
1308
|
-
: humanizeSlackReply(slackOneliner, { allowEmoji: false, maxSentences: 2 });
|
|
1309
|
-
try {
|
|
1310
|
-
await this._slackClient.postAsAgent(channel, slackMsg, devPersona);
|
|
1311
|
-
}
|
|
1312
|
-
catch (err) {
|
|
1313
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1314
|
-
console.warn(`[deliberation][audit] failed to post Slack notification: ${msg}`);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Open a GitHub issue from a code_watch finding and post back to the thread.
|
|
1319
|
-
* Called automatically after an approved code_watch consensus.
|
|
1320
|
-
*/
|
|
1321
|
-
async triggerIssueOpener(discussionId, trigger) {
|
|
1322
|
-
const repos = getRepositories();
|
|
1323
|
-
const discussion = repos.slackDiscussion.getById(discussionId);
|
|
1324
|
-
if (!discussion)
|
|
1325
|
-
return;
|
|
1326
|
-
const devPersona = findDev(repos.agentPersona.getActive());
|
|
1327
|
-
if (!devPersona)
|
|
1328
|
-
return;
|
|
1329
|
-
// Acknowledge before doing async work
|
|
1330
|
-
await this._slackClient.postAsAgent(discussion.channelId, 'Agreed. Writing up an issue for this.', devPersona, discussion.threadTs);
|
|
1331
|
-
const title = buildIssueTitleFromTrigger(trigger);
|
|
1332
|
-
const body = await this._generateIssueBody(trigger, devPersona);
|
|
1333
|
-
const boardConfig = this._resolveBoardConfig(trigger.projectPath);
|
|
1334
|
-
if (boardConfig) {
|
|
1335
|
-
try {
|
|
1336
|
-
const provider = createBoardProvider(boardConfig, trigger.projectPath);
|
|
1337
|
-
const issue = await provider.createIssue({ title, body, column: 'In Progress' });
|
|
1338
|
-
if (issue.column !== 'In Progress') {
|
|
1339
|
-
await provider.moveIssue(issue.number, 'In Progress').catch(() => undefined);
|
|
1340
|
-
}
|
|
1341
|
-
await this._slackClient.postAsAgent(discussion.channelId, `Opened #${issue.number}: ${issue.title} — ${issue.url}\nTaking first pass now. It's in In Progress.`, devPersona, discussion.threadTs);
|
|
1342
|
-
}
|
|
1343
|
-
catch (err) {
|
|
1344
|
-
console.warn('[issue_opener] board createIssue failed:', err);
|
|
1345
|
-
await this._slackClient.postAsAgent(discussion.channelId, `Couldn't open the issue automatically — board might not be configured. Here's the writeup:\n\n${body.slice(0, 600)}`, devPersona, discussion.threadTs);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
else {
|
|
1349
|
-
// No board configured — post the writeup in thread so it's not lost
|
|
1350
|
-
await this._slackClient.postAsAgent(discussion.channelId, `No board configured, dropping the writeup here:\n\n${body.slice(0, 600)}`, devPersona, discussion.threadTs);
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
//# sourceMappingURL=deliberation.js.map
|