oh-my-opencode 4.11.1 → 4.12.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.
- package/.agents/skills/codex-qa/scripts/lib/app-server-client.mjs +132 -82
- package/.agents/skills/codex-qa/scripts/lib/app-server-client.test.js +48 -0
- package/.agents/skills/opencode-qa/scripts/serve-wake-split-probe.sh +361 -84
- package/.agents/skills/work-with-pr/SKILL.md +19 -5
- package/.opencode/skills/work-with-pr/SKILL.md +19 -5
- package/dist/agents/types.d.ts +7 -6
- package/dist/cli/index.js +460 -309
- package/dist/cli-node/index.js +460 -309
- package/dist/features/background-agent/parent-wake-flush-runner.d.ts +1 -0
- package/dist/features/background-agent/parent-wake-history-state.d.ts +1 -0
- package/dist/features/background-agent/parent-wake-session-history.d.ts +1 -47
- package/dist/features/background-agent/parent-wake-session-message.d.ts +47 -0
- package/dist/index.js +593 -310
- package/dist/plugin/tool-execute-before.d.ts +2 -0
- package/dist/plugin-handlers/plugin-components-loader.d.ts +1 -0
- package/dist/skills/ultraresearch/SKILL.md +11 -2
- package/dist/skills/ulw-plan/SKILL.md +2 -1
- package/dist/skills/ulw-plan/references/full-workflow.md +1 -1
- package/dist/skills/ulw-plan/references/intent-clear.md +2 -2
- package/dist/tools/background-task/constants.d.ts +1 -1
- package/dist/tools/delegate-task/sync-session-poller.d.ts +1 -2
- package/dist/tools/delegate-task/sync-session-turns.d.ts +3 -0
- package/dist/tui.js +25 -10
- package/package.json +13 -13
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +24 -2
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +25 -9
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +336 -173
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +284 -33
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/src/cli.ts +11 -0
- package/packages/omo-codex/plugin/components/codegraph/src/hook-types.ts +12 -0
- package/packages/omo-codex/plugin/components/codegraph/src/hook.ts +34 -0
- package/packages/omo-codex/plugin/components/codegraph/src/serve.ts +47 -5
- package/packages/omo-codex/plugin/components/codegraph/src/session-start-worker.ts +7 -5
- package/packages/omo-codex/plugin/components/codegraph/test/hook.test.ts +58 -96
- package/packages/omo-codex/plugin/components/codegraph/test/provisioned-node-guard.test.ts +94 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-node-support.test.ts +33 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-provision.test.ts +48 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve.test.ts +1 -0
- package/packages/omo-codex/plugin/components/codegraph/test/session-start-node-support.test.ts +142 -0
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts +2 -71
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +26 -104
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts +12 -92
- package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +157 -234
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +227 -15
- package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +62 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/test/cli.test.ts +34 -3
- package/packages/omo-codex/plugin/components/teammode/dist/cli.js +94 -0
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +17 -0
- package/packages/omo-codex/plugin/components/teammode/package.json +26 -0
- package/packages/omo-codex/plugin/components/teammode/skills/teammode/SKILL.md +139 -0
- package/packages/omo-codex/plugin/components/teammode/skills/teammode/scripts/team-guide.mjs +111 -0
- package/packages/omo-codex/plugin/components/teammode/skills/teammode/scripts/team-state.mjs +250 -0
- package/packages/omo-codex/plugin/components/teammode/skills/teammode/scripts/team.mjs +220 -0
- package/packages/omo-codex/plugin/components/teammode/src/cli.ts +12 -0
- package/packages/omo-codex/plugin/components/teammode/src/codex-hook.ts +125 -0
- package/packages/omo-codex/plugin/components/teammode/test/thread-title-hook.test.ts +116 -0
- package/packages/omo-codex/plugin/components/teammode/tsconfig.json +25 -0
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/test-support/package-smoke-fixture.ts +158 -0
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +2 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/references/full-workflow.md +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/references/intent-clear.md +2 -2
- package/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts +76 -49
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/package-smoke.test.ts +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +17 -0
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +17 -0
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +17 -0
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +17 -0
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +17 -0
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +17 -0
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +16 -0
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +16 -0
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +16 -0
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +16 -0
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +16 -0
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +17 -0
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +16 -0
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +16 -0
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +16 -0
- package/packages/omo-codex/plugin/package-lock.json +30 -22
- package/packages/omo-codex/plugin/package.json +2 -1
- package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +15 -11
- package/packages/omo-codex/plugin/scripts/sync-hook-status-messages.mjs +15 -4
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +37 -0
- package/packages/omo-codex/plugin/skills/ast-grep/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/comment-checker/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/debugging/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/frontend/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/git-master/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/init-deep/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/lcx-contribute-bug-fix/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/lcx-doctor/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/lcx-report-bug/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/lsp/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/lsp-setup/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/programming/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/refactor/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/remove-ai-slops/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/review-work/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/rules/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/start-work/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/teammode/SKILL.md +139 -0
- package/packages/omo-codex/plugin/skills/teammode/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/teammode/scripts/team-guide.mjs +111 -0
- package/packages/omo-codex/plugin/skills/teammode/scripts/team-state.mjs +250 -0
- package/packages/omo-codex/plugin/skills/teammode/scripts/team.mjs +220 -0
- package/packages/omo-codex/plugin/skills/ultraresearch/SKILL.md +11 -2
- package/packages/omo-codex/plugin/skills/ultraresearch/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/skills/ulw-loop/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +2 -1
- package/packages/omo-codex/plugin/skills/ulw-plan/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/ulw-plan/references/full-workflow.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-plan/references/intent-clear.md +2 -2
- package/packages/omo-codex/plugin/skills/visual-qa/agents/openai.yaml +2 -0
- package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +70 -33
- package/packages/omo-codex/plugin/test/aggregate-manifest.test.mjs +6 -2
- package/packages/omo-codex/plugin/test/aggregate-plugin-fixture.mjs +14 -1
- package/packages/omo-codex/plugin/test/bootstrap-binlinks.test.mjs +1 -1
- package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +14 -6
- package/packages/omo-codex/plugin/test/bootstrap-ps-guard.test.mjs +7 -3
- package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +4 -3
- package/packages/omo-codex/plugin/test/component-hook-contract-cases.mjs +24 -0
- package/packages/omo-codex/plugin/test/display-metadata.test.mjs +83 -0
- package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +31 -19
- package/packages/omo-codex/plugin/test/lcx-bug-skills.test.mjs +3 -3
- package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -0
- package/packages/omo-codex/plugin/test/sync-skills.test.mjs +9 -4
- package/packages/omo-codex/plugin/test/teammode-safety-fixture.mjs +88 -0
- package/packages/omo-codex/plugin/test/teammode-safety.test.mjs +240 -0
- package/packages/omo-codex/plugin/test/ultraresearch-skill-contract.test.mjs +48 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +398 -206
- package/packages/omo-codex/scripts/install-hook-targets.test.mjs +93 -0
- package/packages/omo-codex/scripts/install-lazycodex-version-stamp.test.mjs +2 -2
- package/packages/omo-codex/scripts/install-local-bun-global-update.test.mjs +63 -0
- package/packages/omo-codex/scripts/install-local-entrypoint.test.mjs +109 -1
- package/packages/omo-codex/scripts/install-local.mjs +1 -0
- package/packages/shared-skills/skills/ultraresearch/SKILL.md +11 -2
- package/packages/shared-skills/skills/ulw-plan/SKILL.md +2 -1
- package/packages/shared-skills/skills/ulw-plan/references/full-workflow.md +1 -1
- package/packages/shared-skills/skills/ulw-plan/references/intent-clear.md +2 -2
- package/packages/omo-codex/plugin/hooks/hooks.json +0 -214
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// team-guide.mjs - render the member-facing field manual (guide.md) and the short
|
|
2
|
+
// bootstrap trigger a new thread reads on startup. Pure string builders, no I/O.
|
|
3
|
+
//
|
|
4
|
+
// The manual is the durable, two-channel replacement for per-prompt guidance: the leader
|
|
5
|
+
// writes it once at team creation, and every member thread is told to READ it instead of
|
|
6
|
+
// receiving the rules inline each turn.
|
|
7
|
+
|
|
8
|
+
function memberLine(member) {
|
|
9
|
+
const thread = member.threadId ? ` | thread ${member.threadId}` : " | thread not created yet";
|
|
10
|
+
const wt = member.worktree?.path ? ` | worktree ${member.worktree.path}` : "";
|
|
11
|
+
return `- **${member.id}** (${member.lens}) - ${member.focus}${member.deliverable ? ` -> ${member.deliverable}` : ""}${thread}${wt} [${member.status}]`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function worktreeSection(team) {
|
|
15
|
+
if (!team.worktree?.enabled) {
|
|
16
|
+
return `## Worktrees
|
|
17
|
+
|
|
18
|
+
This team does not use isolated git worktrees. Work in the shared repository checkout
|
|
19
|
+
the leader is running from. If your changes would collide with another member's files,
|
|
20
|
+
that is a coordination signal: mark yourself \`blocked\` and tell the leader at once.`;
|
|
21
|
+
}
|
|
22
|
+
const lines = team.members
|
|
23
|
+
.filter((m) => m.worktree?.path || m.worktree?.branch)
|
|
24
|
+
.map((m) => `- **${m.id}**: worktree \`${m.worktree.path ?? "(assign with bind-thread --cwd)"}\` on branch \`${m.worktree.branch ?? team.worktree.baseBranch}\``);
|
|
25
|
+
return `## Worktrees (ISOLATION IS ON - this is not optional for you)
|
|
26
|
+
|
|
27
|
+
This team runs each member in its own git worktree branched off \`${team.worktree.baseBranch}\`.
|
|
28
|
+
**You MUST work only inside your own worktree.** The very first thing you do is \`cd\` into it;
|
|
29
|
+
every edit, command, and commit happens there. Never touch another member's worktree, and
|
|
30
|
+
never edit files outside your assigned scope. Commit your work so the leader can integrate it.
|
|
31
|
+
|
|
32
|
+
${lines.length ? lines.join("\n") : "- (worktree paths are assigned as threads are bound; read team.json for yours)"}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildGuide(team) {
|
|
36
|
+
const artifacts = team.paths?.artifacts ?? ".omo/teams/<session_id>/artifacts";
|
|
37
|
+
const teamJson = team.paths?.team ?? ".omo/teams/<session_id>/team.json";
|
|
38
|
+
const roster = team.members.length ? team.members.map(memberLine).join("\n") : "- (no members yet)";
|
|
39
|
+
return `# Team ${team.teamName} - Member Field Manual
|
|
40
|
+
|
|
41
|
+
> Auto-generated by the teammode script. This file is the single source of truth for how
|
|
42
|
+
> this team works. Re-read it whenever you are unsure - do not improvise the protocol.
|
|
43
|
+
|
|
44
|
+
- Team: **${team.teamName}** (id \`${team.teamId}\`)
|
|
45
|
+
- Team state: \`${teamJson}\`
|
|
46
|
+
- Thread/session title convention (use it EXACTLY): \`${team.threadTitleConvention}\`
|
|
47
|
+
|
|
48
|
+
## Who you are (your identity is fixed)
|
|
49
|
+
|
|
50
|
+
**You are a member of team ${team.teamName}, not a generic assistant.** You own exactly one
|
|
51
|
+
slice of this team's work - the focus and deliverable assigned to your id below - and you are
|
|
52
|
+
accountable for it end to end. Read your row, internalize it, and act as that owner for the
|
|
53
|
+
whole session. Do not drift into other members' scope.
|
|
54
|
+
|
|
55
|
+
${roster}
|
|
56
|
+
|
|
57
|
+
## Leader
|
|
58
|
+
|
|
59
|
+
**The main session is the leader. There is no other leader, and you are not it.** You report
|
|
60
|
+
to the leader, take direction from the leader, and send your results to the leader. Peers
|
|
61
|
+
coordinate with each other, but the leader owns the final decision and integration.
|
|
62
|
+
|
|
63
|
+
## Communication rules (hard rules)
|
|
64
|
+
|
|
65
|
+
- **All communication between team members, and from you to the leader, is in English.** This
|
|
66
|
+
is mandatory regardless of the language the task was given in.
|
|
67
|
+
- **Exception: when the END USER addresses you directly, reply in the user's own language.**
|
|
68
|
+
Team-internal traffic stays English; user-facing replies match the user.
|
|
69
|
+
- **Report to the leader the moment your work is complete.** A finished task that was never
|
|
70
|
+
reported does not count as done.
|
|
71
|
+
- **Over-communicate relentlessly - report even the trivial.** Message constantly and
|
|
72
|
+
proactively: every finding, decision, hand-off, and even the smallest or most trivial step, the
|
|
73
|
+
moment it happens, in short lean notes. Never save it up for one final report. When in doubt,
|
|
74
|
+
send the message - far more and far smaller updates always beats going quiet. The instant
|
|
75
|
+
anything changes a peer's assumption, push it to them. Too many messages is correct here; the
|
|
76
|
+
only failure is going quiet.
|
|
77
|
+
- **Stale, stuck, and silently-blocked states are NOT acceptable.** During long work send
|
|
78
|
+
\`WORKING: <your focus> - <phase>\` so the leader can see you are alive. If you truly cannot
|
|
79
|
+
proceed, send \`BLOCKED: <reason>\` immediately and propose the smallest unblock - never sit
|
|
80
|
+
idle and never disappear. Being quiet is a failure, not a pause.
|
|
81
|
+
|
|
82
|
+
## Artifacts (how you hand work off)
|
|
83
|
+
|
|
84
|
+
Exchange files and memos through the team artifacts directory:
|
|
85
|
+
|
|
86
|
+
\`${artifacts}\`
|
|
87
|
+
|
|
88
|
+
Drop deliverables, notes, diffs, and findings there as files, and reference them by path in
|
|
89
|
+
your messages instead of pasting large content into a thread. Treat it as the team's shared
|
|
90
|
+
desk: if a peer or the leader needs something from you, leave it in artifacts and point them to it.
|
|
91
|
+
|
|
92
|
+
${worktreeSection(team)}
|
|
93
|
+
|
|
94
|
+
## Done means reported and verified
|
|
95
|
+
|
|
96
|
+
Your slice is done only when (1) the deliverable exists, (2) you have evidence it works, and
|
|
97
|
+
(3) you have reported it to the leader. Until all three are true, keep going.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildMemberPrompt(team, id) {
|
|
101
|
+
const member = team.members.find((m) => m.id === id);
|
|
102
|
+
if (!member) throw new Error(`no member with id "${id}"`);
|
|
103
|
+
const guide = team.paths?.guide ?? ".omo/teams/<session_id>/guide.md";
|
|
104
|
+
const teamJson = team.paths?.team ?? ".omo/teams/<session_id>/team.json";
|
|
105
|
+
const where = member.cwd ? `Work inside \`${member.cwd}\`.` : "Work from the repository root unless your manual assigns a worktree.";
|
|
106
|
+
return `You are member ${member.id} of team ${team.teamName} - owner of: ${member.focus}.
|
|
107
|
+
FIRST read your field manual at \`${guide}\`, then the team state at \`${teamJson}\`; they define your scope, deliverable, the leader, the artifacts directory, and the communication rules.
|
|
108
|
+
${where}
|
|
109
|
+
Communicate with the team and leader in English; reply to the end user in the user's own language.
|
|
110
|
+
Start now, send WORKING/BLOCKED updates so you never look stale, and report to the leader the moment you are done.`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// team-state.mjs - the teammode state model + atomic, symlink-guarded persistence.
|
|
2
|
+
//
|
|
3
|
+
// Zero external dependencies (node builtins only) so it runs byte-identically under
|
|
4
|
+
// `node` and `bun` on macOS, Linux, and Windows with no install step, no POSIX shell,
|
|
5
|
+
// and no python3 precondition. This is the SINGLE source of the team-state shape: the
|
|
6
|
+
// model never hand-writes team.json - it calls these helpers through scripts/team.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Rendering (the member field manual + bootstrap trigger) lives in team-guide.mjs so
|
|
9
|
+
// this file stays the state concern only.
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const LENSES = ["area", "ownership", "perspective"];
|
|
16
|
+
export const MEMBER_STATUSES = ["pending", "active", "reported", "blocked", "archived"];
|
|
17
|
+
// A team is never a single member: two or more distinct slices is the minimum. One isolated
|
|
18
|
+
// job is a subagent, not a team.
|
|
19
|
+
export const MIN_MEMBERS = 2;
|
|
20
|
+
|
|
21
|
+
export function isUnderstaffed(team) {
|
|
22
|
+
return team.members.length < MIN_MEMBERS;
|
|
23
|
+
}
|
|
24
|
+
// A team dir is a single child of .omo/teams. This pattern alone blocks "/", "\", and a
|
|
25
|
+
// leading "." so ".." and "a/b" can never name a team dir (the escape guard).
|
|
26
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
27
|
+
|
|
28
|
+
function isoNow(now) {
|
|
29
|
+
return now ?? new Date().toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildTeam({ teamName, sessionName, sessionId = null, dir = null, worktreeEnabled = false, baseBranch = "dev", now }) {
|
|
33
|
+
if (!teamName?.trim()) throw new Error("team name is required");
|
|
34
|
+
if (!sessionName?.trim()) throw new Error("session name is required");
|
|
35
|
+
const ts = isoNow(now);
|
|
36
|
+
return {
|
|
37
|
+
schemaVersion: 2,
|
|
38
|
+
teamId: randomUUID(),
|
|
39
|
+
teamName: teamName.trim(),
|
|
40
|
+
sessionName: sessionName.trim(),
|
|
41
|
+
sessionId,
|
|
42
|
+
threadTitleConvention: `[${teamName.trim()}] ${sessionName.trim()}`,
|
|
43
|
+
status: "active",
|
|
44
|
+
createdAt: ts,
|
|
45
|
+
updatedAt: ts,
|
|
46
|
+
archivedAt: null,
|
|
47
|
+
leader: { kind: "main-session", sessionId },
|
|
48
|
+
communication: { memberLanguage: "english", replyToUserInUserLanguage: true },
|
|
49
|
+
worktree: { enabled: Boolean(worktreeEnabled), baseBranch, root: dir ? join(dir, "worktrees") : null },
|
|
50
|
+
paths: dir
|
|
51
|
+
? { dir, team: join(dir, "team.json"), guide: join(dir, "guide.md"), artifacts: join(dir, "artifacts") }
|
|
52
|
+
: null,
|
|
53
|
+
members: [],
|
|
54
|
+
log: [{ ts, event: "created", detail: `team ${teamName.trim()}` }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function touch(team, event, detail) {
|
|
59
|
+
const ts = isoNow();
|
|
60
|
+
team.updatedAt = ts;
|
|
61
|
+
team.log.push({ ts, event, detail });
|
|
62
|
+
return team;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function memberById(team, id) {
|
|
66
|
+
const found = team.members.find((m) => m.id === id);
|
|
67
|
+
if (!found) throw new Error(`no member with id "${id}"`);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizedFocus(focus) {
|
|
72
|
+
return focus.trim().replace(/\s+/g, " ").toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertUniqueMemberFocus(team) {
|
|
76
|
+
const seen = new Map();
|
|
77
|
+
for (const member of team.members) {
|
|
78
|
+
const key = normalizedFocus(member.focus ?? "");
|
|
79
|
+
const previous = seen.get(key);
|
|
80
|
+
if (previous) {
|
|
81
|
+
throw new Error(`member focus "${member.focus}" duplicates "${previous.focus}" (no two members may own the same thing)`);
|
|
82
|
+
}
|
|
83
|
+
seen.set(key, member);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertTeamReadyForThreadBinding(team) {
|
|
88
|
+
if (isUnderstaffed(team)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`cannot bind member threads until the team has at least ${MIN_MEMBERS} distinct members; current count is ${team.members.length}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
assertUniqueMemberFocus(team);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function addMember(team, { id, focus, lens, deliverable = "", branch = null }) {
|
|
97
|
+
if (!id?.trim()) throw new Error("member id is required");
|
|
98
|
+
if (!focus?.trim()) throw new Error("member focus is required - a concrete part, ownership area, or perspective");
|
|
99
|
+
if (!LENSES.includes(lens)) throw new Error(`invalid lens "${lens}" - use one of: ${LENSES.join(", ")}`);
|
|
100
|
+
const memberId = id.trim();
|
|
101
|
+
const memberFocus = focus.trim();
|
|
102
|
+
if (team.members.some((m) => m.id === memberId)) throw new Error(`member id "${memberId}" already exists (duplicate)`);
|
|
103
|
+
const duplicate = team.members.find((m) => normalizedFocus(m.focus) === normalizedFocus(memberFocus));
|
|
104
|
+
if (duplicate) throw new Error(`member focus "${memberFocus}" duplicates "${duplicate.focus}" (no two members may own the same thing)`);
|
|
105
|
+
team.members.push({
|
|
106
|
+
id: memberId,
|
|
107
|
+
focus: memberFocus,
|
|
108
|
+
lens,
|
|
109
|
+
deliverable: deliverable.trim(),
|
|
110
|
+
threadId: null,
|
|
111
|
+
threadTitle: team.threadTitleConvention,
|
|
112
|
+
cwd: null,
|
|
113
|
+
worktree: { path: null, branch: branch ?? null },
|
|
114
|
+
status: "pending",
|
|
115
|
+
});
|
|
116
|
+
return touch(team, "add-member", `member ${memberId} (${lens}): ${memberFocus}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function bindThread(team, { id, threadId, cwd = null, worktreePath = null }) {
|
|
120
|
+
if (!threadId?.trim()) throw new Error("thread id is required");
|
|
121
|
+
assertTeamReadyForThreadBinding(team);
|
|
122
|
+
const m = memberById(team, id);
|
|
123
|
+
m.threadId = threadId.trim();
|
|
124
|
+
m.status = "active";
|
|
125
|
+
if (cwd) m.cwd = cwd;
|
|
126
|
+
if (team.worktree.enabled) m.worktree.path = worktreePath ?? cwd ?? m.worktree.path;
|
|
127
|
+
return touch(team, "bind-thread", `member ${id} -> thread ${threadId.trim()}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function setMemberStatus(team, { id, status, note = "" }) {
|
|
131
|
+
if (!MEMBER_STATUSES.includes(status)) throw new Error(`invalid status "${status}" - use one of: ${MEMBER_STATUSES.join(", ")}`);
|
|
132
|
+
memberById(team, id).status = status;
|
|
133
|
+
return touch(team, "set-status", `member ${id} -> ${status}${note ? `: ${note}` : ""}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function archive(team, { id = null, note = "" } = {}) {
|
|
137
|
+
if (id) {
|
|
138
|
+
memberById(team, id).status = "archived";
|
|
139
|
+
return touch(team, "archive-member", `member ${id}${note ? `: ${note}` : ""}`);
|
|
140
|
+
}
|
|
141
|
+
for (const m of team.members) m.status = "archived";
|
|
142
|
+
team.status = "archived";
|
|
143
|
+
team.archivedAt = isoNow();
|
|
144
|
+
return touch(team, "archive", note || "team archived; all members closed");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function validateTeam(team) {
|
|
148
|
+
if (team?.schemaVersion !== 2) throw new Error("invalid team: schemaVersion must be 2");
|
|
149
|
+
if (!team.teamId || !team.teamName) throw new Error("invalid team: teamId and teamName are required");
|
|
150
|
+
if (team.leader?.kind !== "main-session") throw new Error("invalid team: leader.kind must be main-session");
|
|
151
|
+
if (!Array.isArray(team.members)) throw new Error("invalid team: members must be an array");
|
|
152
|
+
assertUniqueMemberFocus(team);
|
|
153
|
+
return team;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Resolve (and confine) a team dir to a single child of <cwd>/.omo/teams.
|
|
157
|
+
export function resolveTeamDir(cwd, sessionId) {
|
|
158
|
+
if (!SESSION_ID_PATTERN.test(sessionId ?? "")) throw new Error(`invalid or unsafe session id "${sessionId}"`);
|
|
159
|
+
return resolve(cwd, ".omo", "teams", sessionId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function lstatOrNull(p) {
|
|
163
|
+
return lstat(p).catch((error) => {
|
|
164
|
+
if (error && error.code === "ENOENT") return null;
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create a directory chain refusing any symlinked component, so team state can never be
|
|
170
|
+
// written through a symlink that escapes the workspace.
|
|
171
|
+
async function mkdirNoSymlink(dir, stopAt) {
|
|
172
|
+
if (dir === stopAt) return;
|
|
173
|
+
const rel = relative(stopAt, dir);
|
|
174
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`refused: path escapes ${stopAt}: ${dir}`);
|
|
175
|
+
await mkdirNoSymlink(dirname(dir), stopAt);
|
|
176
|
+
const st = await lstatOrNull(dir);
|
|
177
|
+
if (st) {
|
|
178
|
+
if (st.isSymbolicLink()) throw new Error(`refused: path component is a symlink: ${dir}`);
|
|
179
|
+
if (!st.isDirectory()) throw new Error(`refused: path component is not a directory: ${dir}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await mkdir(dir);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function assertNoSymlinkComponents(dir, stopAt) {
|
|
186
|
+
if (dir === stopAt) return;
|
|
187
|
+
const rel = relative(stopAt, dir);
|
|
188
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`refused: path escapes ${stopAt}: ${dir}`);
|
|
189
|
+
await assertNoSymlinkComponents(dirname(dir), stopAt);
|
|
190
|
+
const st = await lstatOrNull(dir);
|
|
191
|
+
if (st?.isSymbolicLink()) throw new Error(`refused: path component is a symlink: ${dir}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function ensureTeamDir(cwd, sessionId) {
|
|
195
|
+
const workspaceRoot = resolve(cwd);
|
|
196
|
+
const teamsRoot = resolve(cwd, ".omo", "teams");
|
|
197
|
+
const dir = resolveTeamDir(cwd, sessionId);
|
|
198
|
+
await mkdirNoSymlink(teamsRoot, workspaceRoot);
|
|
199
|
+
await mkdirNoSymlink(dir, teamsRoot);
|
|
200
|
+
await mkdirNoSymlink(join(dir, "artifacts"), dir);
|
|
201
|
+
return dir;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function assertSafeTeamDir(cwd, sessionId) {
|
|
205
|
+
const workspaceRoot = resolve(cwd);
|
|
206
|
+
const teamsRoot = resolve(cwd, ".omo", "teams");
|
|
207
|
+
const dir = resolveTeamDir(cwd, sessionId);
|
|
208
|
+
await assertNoSymlinkComponents(teamsRoot, workspaceRoot);
|
|
209
|
+
await assertNoSymlinkComponents(dir, teamsRoot);
|
|
210
|
+
return dir;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function readTeam(dir) {
|
|
214
|
+
return validateTeam(JSON.parse(await readFile(join(dir, "team.json"), "utf8")));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function teamExists(dir) {
|
|
218
|
+
return (await lstatOrNull(join(dir, "team.json"))) !== null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function persistedFileTarget(team, pathKey, fileName, expectedDir) {
|
|
222
|
+
validateTeam(team);
|
|
223
|
+
if (!team.paths?.dir || !team.paths?.[pathKey]) throw new Error(`invalid team: paths.${pathKey} is required`);
|
|
224
|
+
const dir = resolve(expectedDir);
|
|
225
|
+
if (resolve(team.paths.dir) !== dir) throw new Error(`refused: persisted team dir does not match trusted team dir: ${team.paths.dir}`);
|
|
226
|
+
const target = resolve(team.paths[pathKey]);
|
|
227
|
+
if (target !== resolve(dir, fileName)) throw new Error(`refused: ${fileName} persist target escapes team dir: ${team.paths[pathKey]}`);
|
|
228
|
+
const dirStat = await lstatOrNull(dir);
|
|
229
|
+
if (dirStat?.isSymbolicLink()) throw new Error(`refused: team dir is a symlink: ${dir}`);
|
|
230
|
+
const st = await lstatOrNull(target);
|
|
231
|
+
if (st?.isSymbolicLink()) throw new Error(`refused: ${fileName} is a symlink: ${target}`);
|
|
232
|
+
if (st && !st.isFile()) throw new Error(`refused: ${fileName} is not a file: ${target}`);
|
|
233
|
+
return target;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function writePersistedFileAtomic(team, pathKey, fileName, content, expectedDir) {
|
|
237
|
+
const target = await persistedFileTarget(team, pathKey, fileName, expectedDir);
|
|
238
|
+
const tmp = `${target}.tmp-${process.pid}-${randomUUID()}`;
|
|
239
|
+
await writeFile(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
240
|
+
await rename(tmp, target);
|
|
241
|
+
return team;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function writeTeamAtomic(team, expectedDir) {
|
|
245
|
+
return writePersistedFileAtomic(team, "team", "team.json", `${JSON.stringify(team, null, 2)}\n`, expectedDir);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function writeGuideAtomic(team, content, expectedDir) {
|
|
249
|
+
return writePersistedFileAtomic(team, "guide", "guide.md", content, expectedDir);
|
|
250
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// team.mjs - the teammode controller CLI. Thin argv dispatch over team-state.mjs (the
|
|
3
|
+
// state model) and team-guide.mjs (the member field manual). It replaces hand-written
|
|
4
|
+
// team.json / guide.md so the model never has to author those files by hand.
|
|
5
|
+
//
|
|
6
|
+
// Zero external dependencies (node builtins only): runs identically under node and bun on
|
|
7
|
+
// macOS, Linux, and Windows.
|
|
8
|
+
//
|
|
9
|
+
// node "<skill-root>/scripts/team.mjs" init --name "<team>" --session-name "<sess>" [--session <id>] [--worktree] [--base-branch dev]
|
|
10
|
+
// node "<skill-root>/scripts/team.mjs" add-member --team <id> --id A --focus "<part/ownership/perspective>" --lens area|ownership|perspective --deliverable "<...>" [--branch <b>]
|
|
11
|
+
// node "<skill-root>/scripts/team.mjs" bind-thread --team <id> --id A --thread <thread-id> [--cwd <path>]
|
|
12
|
+
// node "<skill-root>/scripts/team.mjs" member-prompt --team <id> --id A
|
|
13
|
+
// node "<skill-root>/scripts/team.mjs" set-status --team <id> --id A --status reported|blocked|active|archived [--note "<...>"]
|
|
14
|
+
// node "<skill-root>/scripts/team.mjs" archive --team <id> [--id A] [--note "<...>"]
|
|
15
|
+
// node "<skill-root>/scripts/team.mjs" delete --team <id> [--force]
|
|
16
|
+
// node "<skill-root>/scripts/team.mjs" status --team <id>
|
|
17
|
+
// node "<skill-root>/scripts/team.mjs" guide --team <id>
|
|
18
|
+
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { realpathSync } from "node:fs";
|
|
21
|
+
import { rm } from "node:fs/promises";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
import { buildGuide, buildMemberPrompt } from "./team-guide.mjs";
|
|
24
|
+
import {
|
|
25
|
+
addMember,
|
|
26
|
+
archive,
|
|
27
|
+
assertSafeTeamDir,
|
|
28
|
+
bindThread,
|
|
29
|
+
buildTeam,
|
|
30
|
+
ensureTeamDir,
|
|
31
|
+
isUnderstaffed,
|
|
32
|
+
MIN_MEMBERS,
|
|
33
|
+
readTeam,
|
|
34
|
+
resolveTeamDir,
|
|
35
|
+
setMemberStatus,
|
|
36
|
+
teamExists,
|
|
37
|
+
writeGuideAtomic,
|
|
38
|
+
writeTeamAtomic,
|
|
39
|
+
} from "./team-state.mjs";
|
|
40
|
+
|
|
41
|
+
function parseFlags(args) {
|
|
42
|
+
const flags = { _: [] };
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
const arg = args[i];
|
|
45
|
+
if (!arg.startsWith("--")) {
|
|
46
|
+
flags._.push(arg);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const key = arg.slice(2);
|
|
50
|
+
const next = args[i + 1];
|
|
51
|
+
if (next === undefined || next.startsWith("--")) {
|
|
52
|
+
flags[key] = true;
|
|
53
|
+
} else {
|
|
54
|
+
flags[key] = next;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return flags;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireFlag(flags, name) {
|
|
62
|
+
const value = flags[name];
|
|
63
|
+
if (value === undefined || value === true) throw new Error(`missing required flag --${name}`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadTeam(cwd, sessionId) {
|
|
68
|
+
const dir = await assertSafeTeamDir(cwd, sessionId);
|
|
69
|
+
return { dir, team: await readTeam(dir) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function persist(team, dir) {
|
|
73
|
+
await writeTeamAtomic(team, dir);
|
|
74
|
+
await writeGuideAtomic(team, buildGuide(team), dir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const handlers = {
|
|
78
|
+
async init(cwd, flags) {
|
|
79
|
+
const teamName = requireFlag(flags, "name");
|
|
80
|
+
const sessionName = requireFlag(flags, "session-name");
|
|
81
|
+
const sessionId = typeof flags.session === "string" ? flags.session : `team-${randomUUID().slice(0, 8)}`;
|
|
82
|
+
const dir = await ensureTeamDir(cwd, sessionId);
|
|
83
|
+
if (await teamExists(dir)) {
|
|
84
|
+
process.stdout.write(`exists: ${dir} (left untouched; re-init is a safe no-op)\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const team = buildTeam({
|
|
88
|
+
teamName,
|
|
89
|
+
sessionName,
|
|
90
|
+
sessionId,
|
|
91
|
+
dir,
|
|
92
|
+
worktreeEnabled: flags.worktree === true,
|
|
93
|
+
baseBranch: typeof flags["base-branch"] === "string" ? flags["base-branch"] : "dev",
|
|
94
|
+
});
|
|
95
|
+
await persist(team, dir);
|
|
96
|
+
process.stdout.write(`created: ${dir}\n`);
|
|
97
|
+
process.stdout.write(`team.json + guide.md written; artifacts/ ready. session id: ${sessionId}\n`);
|
|
98
|
+
process.stdout.write(`next: add-member --team ${sessionId} --id A --focus "<part/ownership/perspective>" --lens area|ownership|perspective --deliverable "<...>"\n`);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async "add-member"(cwd, flags) {
|
|
102
|
+
const sessionId = requireFlag(flags, "team");
|
|
103
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
104
|
+
const memberId = requireFlag(flags, "id").trim();
|
|
105
|
+
addMember(team, {
|
|
106
|
+
id: memberId,
|
|
107
|
+
focus: requireFlag(flags, "focus"),
|
|
108
|
+
lens: requireFlag(flags, "lens"),
|
|
109
|
+
deliverable: typeof flags.deliverable === "string" ? flags.deliverable : "",
|
|
110
|
+
branch: typeof flags.branch === "string" ? flags.branch : null,
|
|
111
|
+
});
|
|
112
|
+
await persist(team, dir);
|
|
113
|
+
process.stdout.write(`added member ${memberId} to team ${sessionId}.\n\nSend this as the new thread's first message (title it "${team.threadTitleConvention}"):\n---\n${buildMemberPrompt(team, memberId)}\n---\n`);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async "bind-thread"(cwd, flags) {
|
|
117
|
+
const sessionId = requireFlag(flags, "team");
|
|
118
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
119
|
+
bindThread(team, {
|
|
120
|
+
id: requireFlag(flags, "id"),
|
|
121
|
+
threadId: requireFlag(flags, "thread"),
|
|
122
|
+
cwd: typeof flags.cwd === "string" ? flags.cwd : null,
|
|
123
|
+
worktreePath: typeof flags["worktree-path"] === "string" ? flags["worktree-path"] : null,
|
|
124
|
+
});
|
|
125
|
+
await persist(team, dir);
|
|
126
|
+
process.stdout.write(`bound member ${flags.id} to thread ${flags.thread}.\n`);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async "member-prompt"(cwd, flags) {
|
|
130
|
+
const sessionId = requireFlag(flags, "team");
|
|
131
|
+
const { team } = await loadTeam(cwd, sessionId);
|
|
132
|
+
process.stdout.write(`${buildMemberPrompt(team, requireFlag(flags, "id"))}\n`);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async "set-status"(cwd, flags) {
|
|
136
|
+
const sessionId = requireFlag(flags, "team");
|
|
137
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
138
|
+
setMemberStatus(team, {
|
|
139
|
+
id: requireFlag(flags, "id"),
|
|
140
|
+
status: requireFlag(flags, "status"),
|
|
141
|
+
note: typeof flags.note === "string" ? flags.note : "",
|
|
142
|
+
});
|
|
143
|
+
await persist(team, dir);
|
|
144
|
+
process.stdout.write(`member ${flags.id} -> ${flags.status}\n`);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async archive(cwd, flags) {
|
|
148
|
+
const sessionId = requireFlag(flags, "team");
|
|
149
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
150
|
+
archive(team, {
|
|
151
|
+
id: typeof flags.id === "string" ? flags.id : null,
|
|
152
|
+
note: typeof flags.note === "string" ? flags.note : "",
|
|
153
|
+
});
|
|
154
|
+
await persist(team, dir);
|
|
155
|
+
process.stdout.write(flags.id ? `archived member ${flags.id}\n` : `archived team ${sessionId} and closed all members\n`);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async delete(cwd, flags) {
|
|
159
|
+
const sessionId = requireFlag(flags, "team");
|
|
160
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
161
|
+
const active = team.members.filter((m) => m.status !== "archived");
|
|
162
|
+
if (flags.force !== true && (team.status !== "archived" || active.length > 0)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`refused: team "${sessionId}" is not archived or still has ${active.length} active member(s). Archive first, or pass --force to delete anyway.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
await rm(dir, { recursive: true, force: true });
|
|
168
|
+
process.stdout.write(`deleted team state: ${dir}\n`);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async status(cwd, flags) {
|
|
172
|
+
const sessionId = requireFlag(flags, "team");
|
|
173
|
+
const { team } = await loadTeam(cwd, sessionId);
|
|
174
|
+
process.stdout.write(`Team ${team.teamName} [${team.status}] - leader: main session - ${team.members.length} member(s)\n`);
|
|
175
|
+
for (const m of team.members) {
|
|
176
|
+
process.stdout.write(` ${m.id} (${m.lens}) ${m.focus} -> ${m.deliverable || "(no deliverable)"} [${m.status}]${m.threadId ? ` thread=${m.threadId}` : ""}${m.cwd ? ` cwd=${m.cwd}` : ""}\n`);
|
|
177
|
+
}
|
|
178
|
+
if (isUnderstaffed(team)) {
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
`WARNING: a team needs at least ${MIN_MEMBERS} members; this team has ${team.members.length}. A single-member team is not a team - add another distinct slice or use a subagent.\n`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async guide(cwd, flags) {
|
|
186
|
+
const sessionId = requireFlag(flags, "team");
|
|
187
|
+
const { dir, team } = await loadTeam(cwd, sessionId);
|
|
188
|
+
await writeGuideAtomic(team, buildGuide(team), dir);
|
|
189
|
+
process.stdout.write(`${team.paths.guide}\n`);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
async function main() {
|
|
194
|
+
const [subcommand, ...rest] = process.argv.slice(2);
|
|
195
|
+
const handler = handlers[subcommand];
|
|
196
|
+
if (!handler) {
|
|
197
|
+
throw new Error(`unknown subcommand "${subcommand ?? ""}" - expected one of: ${Object.keys(handlers).join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
await handler(process.cwd(), parseFlags(rest));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Symlink-robust main-module check: a symlinked install invokes this file through a symlink
|
|
203
|
+
// path while node resolves import.meta.url to the realpath, so compare the resolved real paths
|
|
204
|
+
// (falling back to the literal url comparison if a path cannot be resolved).
|
|
205
|
+
function isInvokedAsScript() {
|
|
206
|
+
const entry = process.argv[1];
|
|
207
|
+
if (!entry) return false;
|
|
208
|
+
try {
|
|
209
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry);
|
|
210
|
+
} catch {
|
|
211
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isInvokedAsScript()) {
|
|
216
|
+
await main().catch((error) => {
|
|
217
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -49,6 +49,14 @@ The research is done when all of these hold:
|
|
|
49
49
|
- Every claim in the deliverable cites a source or a verification artifact.
|
|
50
50
|
- The session journal reconstructs what was searched, found, and expanded, wave by wave.
|
|
51
51
|
|
|
52
|
+
## Run the swarm as a cooperating team
|
|
53
|
+
|
|
54
|
+
Saturation research is the textbook case for a cooperating team, not isolated fire-and-forget workers: a lead one worker surfaces almost always reshapes what another should search next. So when your harness gives you real cooperating members — Codex: the `teammode` skill (`codex_app` threads); OpenCode: `team_mode` — run this swarm as a team. Fall back to the background-worker swarm below only when team mode is unavailable, or the axes are genuinely independent with no cross-pollination expected.
|
|
55
|
+
|
|
56
|
+
- **One member per axis — by part, ownership, or perspective, never a job title.** Each Phase 0 axis is one member owning one concrete slice: a codebase part, a source territory, or a question lens. No two members share an angle. "Backend researcher" or "the web person" gives no real boundary and invites overlap — name what the member owns.
|
|
57
|
+
- **The raise law — broadcast every lead the instant it surfaces.** Members over-communicate relentlessly: every new lead, finding, contradiction, and dead end is raised to you the moment it surfaces, never hoarded for a final dump. Through long passes they send `WORKING: <axis> - <phase>`, and `BLOCKED: <reason>` the moment progress stops, so you always know a member is alive. Too many small updates is correct here; going quiet is the only failure.
|
|
58
|
+
- **You lead; expand on each raised lead.** Members raise via message text, never write session files. Journal each lead and spawn its expansion the instant it lands (Phase 2), not only when a member's final reply arrives.
|
|
59
|
+
|
|
52
60
|
## Worker ground rules
|
|
53
61
|
|
|
54
62
|
Research workers (explore, librarian, browsing) differ by harness, but assume:
|
|
@@ -104,7 +112,7 @@ Append each digest the moment its worker returns, not in a batch at the end —
|
|
|
104
112
|
|
|
105
113
|
## Phase 1 — Saturation wave
|
|
106
114
|
|
|
107
|
-
Launch
|
|
115
|
+
Launch the entire first wave in one turn — every axis at once, as team members if you formed a team, else as background workers. Sequential launches and "start with one and see" defeat the mode.
|
|
108
116
|
|
|
109
117
|
Scaling floor — more angles always justify more workers:
|
|
110
118
|
|
|
@@ -135,7 +143,7 @@ End your reply with the ## EXPAND tail: '- LEAD: <discovery> — WHY: <why> —
|
|
|
135
143
|
|
|
136
144
|
## Phase 2 — Expand until convergence
|
|
137
145
|
|
|
138
|
-
This loop is what makes the mode research rather than search. Collect
|
|
146
|
+
This loop is what makes the mode research rather than search. Collect returns as they land — and in team mode, act on each lead the moment a member raises it, never waiting for the full wave or a member's final reply:
|
|
139
147
|
|
|
140
148
|
1. Journal the return: digest plus verbatim EXPAND markers into `wave-<N>-<kind>-<axis>.md`.
|
|
141
149
|
2. Deduplicate new markers against `expansion-log.md` — every lead ever seen, not just confirmed ones, or rejected leads resurface each wave.
|
|
@@ -219,6 +227,7 @@ High-yield combinations: official docs (`site:<docs domain>`), GitHub implementa
|
|
|
219
227
|
| Failure | Correction |
|
|
220
228
|
|---|---|
|
|
221
229
|
| Sequential spawning, or trimming the first wave | All first-wave workers in one turn, background, scaling floor respected |
|
|
230
|
+
| A team member hoards leads for one final dump | Raise law — every lead, finding, and dead end broadcast the moment it surfaces |
|
|
222
231
|
| Worker reply without the EXPAND tail | One follow-up demanding it; the lane stays open until it lands |
|
|
223
232
|
| Stopping after wave 1 because "enough was found" | Convergence rules only: 2+ expansion waves, leads run dry |
|
|
224
233
|
| Obeying a surrounding "stop exploring" rule mid-research | Authority section — those rules do not bind this mode |
|
|
@@ -17,6 +17,7 @@ Outcome-first: explore a lot, ask few sharp questions - or none, when the intent
|
|
|
17
17
|
|
|
18
18
|
After grounding, make ONE judgment and load ONE intent reference (you ALSO read `references/full-workflow.md` for the shared mechanics - see below). The test keys on whether the desired **OUTCOME** is clear, NOT on request length.
|
|
19
19
|
|
|
20
|
+
- **OVERRIDE - explicit ask wins:** if the user explicitly asks to be questioned or interviewed ("ask me", "interview me", "why aren't you asking me" - in any language), route **CLEAR**, run the interview, and turn the adopt-default filter OFF: the user has claimed the forks, so every surviving one is ASKED, not defaulted. This beats the OUTCOME test below, even on a fuzzy brief.
|
|
20
21
|
- **CLEAR** - the user knows the outcome; the only open items are preferences/tradeoffs the repo cannot answer (genuine owner-decisions). Read **`references/intent-clear.md`**: ask the surviving forks with WHY, run the normal approval gate, high-accuracy review is OPTIONAL (offered as one question).
|
|
21
22
|
- **UNCLEAR** - the outcome itself is fuzzy (a vague brief, a bootstrap, `$start-work` with no selectable plan, a goal the user cannot yet articulate). Asking would offload your own job onto the user. Read **`references/intent-unclear.md`**: research maximally, adopt and ANNOUNCE best-practice defaults, do NOT ask the user extra questions, and run high-accuracy review AUTOMATICALLY (unless Classify sized the work Trivial).
|
|
22
23
|
- **ON THE FENCE** - when CLEAR vs UNCLEAR is genuinely ambiguous, treat it as CLEAR and ask exactly ONE question. A user wrongly silenced is worse than one extra question. The dominant failure to guard against is mis-routing a CLEAR request to UNCLEAR, which silently applies defaults and overrides forks the user wanted to own.
|
|
@@ -42,7 +43,7 @@ Run it ONCE at plan generation. A plain re-run on an existing plan is a safe no-
|
|
|
42
43
|
- **Decision-complete is the north star.** The executor has NO interview context - spell out exact paths, "every X in Y", and an explicit Must-NOT-Have. Leave the implementer ZERO judgment calls.
|
|
43
44
|
- **Explore before asking.** Discoverable facts (repo/system/docs truth) -> research and cite, never ask. Preferences/tradeoffs -> the only things you bring to the user. When unsure which, treat it as a user-decision.
|
|
44
45
|
- **CodeGraph first when present.** Use `codegraph_explore` for repo how/where/what/flow questions before wider reads; if codegraph_* tools are absent, inactive/uninitialized, or cold-start unavailable, continue with Read/Grep/Glob/LSP and the ast-grep skill.
|
|
45
|
-
- **Two filters** on every candidate question, in order: (1) Could collected evidence answer it? -> explore instead. (2) Could the user's stated intent plus a defensible default answer it? -> adopt the default, record it, do not ask
|
|
46
|
+
- **Two filters** on every candidate question, in order: (1) Could collected evidence answer it? -> explore instead. (2) Could the user's stated intent plus a defensible default answer it? -> adopt the default, record it, do not ask - UNLESS it is an owner-decision, which always survives as a question even when a default exists: anything irreversible / destructive / safety-critical, or a cross-cutting product choice the user lives with (public config surface, distribution / packaging, external dependency or pinned SHA, data / schema shape). Default the reversible internals; surface the owner-decisions.
|
|
46
47
|
- **Explore to sufficiency, then STOP.** One research wave per open question; stop when the clearance check is answerable; never re-explore to double-check.
|
|
47
48
|
- **Parallel-dispatch** independent research in ONE turn and keep working while it runs. Subagent outputs are CLAIMS until you independently verify them.
|
|
48
49
|
- **Approval is not execution.** Approval authorizes writing the plan ONLY, never implementation. ONE request -> ONE plan, however large.
|