@infinisys/is-fabric-team 0.1.0 → 0.2.0
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/README.md +22 -7
- package/client.js +3 -3
- package/cordis.patch.yml +5 -1
- package/index.js +29 -1
- package/lib/criterion-amend.js +23 -7
- package/lib/cycle-close.js +7 -63
- package/lib/lane-action.js +106 -0
- package/lib/lanes.js +391 -0
- package/lib/ledger-guards.js +57 -0
- package/lib/open-work.js +27 -27
- package/lib/problem-gate.js +179 -58
- package/lib/problem-host.js +97 -3
- package/lib/publish-action.js +8 -5
- package/lib/session-dir.js +21 -0
- package/lib/team-core.js +155 -155
- package/lib/team-model.js +4 -4
- package/lib/tool-schema.js +18 -2
- package/lib/verdict-pool.mjs +177 -0
- package/lib/verdict-worker.mjs +35 -0
- package/lib/watchdog.js +3 -0
- package/lib/work-record-gate.js +2 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @infinisys/is-fabric-team
|
|
2
2
|
|
|
3
|
+
> **会改动 DSH 的默认行为。** 它会关掉原生的 `tool-agent-team` 与 `ui-agent-team`,把 `subagent` 容量改成 16 个并发、深度 3,并注册四个 Agent 预设。后果与退回写法见 [docs/disclaimer.md](https://gitfox.studio/infinity/Fabric/-/blob/master/docs/disclaimer.md)。
|
|
4
|
+
|
|
3
5
|
Infinity Fabric 的 **AI 团队插件**(v2):新增 **团队协作模式(Team Collaboration Mode)** 的四个 Agent 预设。会话根身份是 **assistant**;插件自动拉起 `lead` 和 8 个角色;章程/名册/身份是本地状态;GitFox 只跟踪工作项。工具面是**结构性**的:每个角色跑只挂载它该有能力的那套工具的预设,助手和 Lead 根本没有 shell / 写文件工具可"揽活"。
|
|
4
6
|
|
|
5
7
|
## 预设与工具面
|
|
@@ -103,22 +105,34 @@ GitFox 通过 `web_fetch` 或 shell `curl`/`wget` 访问会被硬拒绝:议题
|
|
|
103
105
|
|
|
104
106
|
| 环节 | 门禁读什么 |
|
|
105
107
|
| --- | --- |
|
|
106
|
-
| `intake` | 每条验收标准必须配一条**可执行 check**(同序、一行一条)。没有 check 的标准只是观点。门禁不看命令的措辞,只看它的运行记录:`true` / `echo` / `ls`
|
|
108
|
+
| `intake` | 每条验收标准必须配一条**可执行 check**(同序、一行一条)。没有 check 的标准只是观点。门禁不看命令的措辞,只看它的运行记录:`true` / `echo` / `ls` 不会被拒绝,它们只会永远“从未失败过”,这会被标记出来而不是当成罪证。**一轮没结题时不会拒收**:新需求当场开成自己的车道,从 `intake` 起步,不必等前面那件交付——请求不会因为前面卡住而丢掉 |
|
|
107
109
|
| `implementation` | Host 亲眼见过的 `infinity_git commit`。没有提交,阶段不动 |
|
|
108
110
|
| `test` | 每条标准的 check 至少真跑过一次 |
|
|
109
111
|
| `review` / `delivery` | 每条 check **至少真跑过一次**(`problem-unresolved` 只在有标准一次都没跑过时出现)。红、旧都不再挡住阶段:绿不绿照样写进 `problem`,由席位判断;“从未失败过”仍然在 `problem.neverFailed` 里点名 |
|
|
110
|
-
| 关议题 / 合 MR |
|
|
112
|
+
| 关议题 / 合 MR | **只在本轮需求自己的那张议题(或它绑定的合入请求)上、且本轮确实有可执行 check 时才管**,门槛与阶段一致:每条 check 至少跑过一次就放行,红的也算跑过(关闭是记账,不是“修好了”的声明)。一次都没跑过的标准仍然拦住,因为那是没看过,不是没通过。合入还按队列走:请求在合入队列里但前面还有车道时拒绝,并点名谁先走。会议、决策、别的项目的议题一律放行;本轮没有需求在跑时也一律放行 |
|
|
111
113
|
| 工作流推进 | 只写本地账本,不再为每个阶段开议题 |
|
|
112
114
|
|
|
113
115
|
所以“通过门禁”不是一个可以单独做的动作:先让那条 check 跑起来,修好,然后在最终提交上把那条命令跑成绿色。形式(标准 + check)只是实质(行为)的可执行描述——正如形式化验证的目标是行为一致,而不是形式化验证本身。删掉门禁,工作仍然成立;删掉工作,门禁无法通过。
|
|
114
116
|
|
|
115
117
|
三道墙曾经互相咬合:评审阶段要求“先红过才准绿”(天生为绿的 check 永远过不了)、每个需求最多三个决策(第四个只能弃周期重开)、`vote` 阶段又必须先有一个**未决**决策(决策在别处通过过就再也进不去)。三条合起来,工作做得再完整也只能以 `abandon` 收场。现在三条都撤了,留下的是治理核心:决策只能由四席一致投票产生,实现阶段必须有已通过的决策。
|
|
116
118
|
|
|
117
|
-
后来又有三道墙同样把人卡死,一并撤掉:**新鲜度**(提交一次就把所有跑绿的验收作废,于是任何一边干活一边提交的团队永远 0 绿,只能弃周期——现在绿的跑就作数,之后的提交只在 `criterionStatus` 里记成 `staleAgainst`
|
|
119
|
+
后来又有三道墙同样把人卡死,一并撤掉:**新鲜度**(提交一次就把所有跑绿的验收作废,于是任何一边干活一边提交的团队永远 0 绿,只能弃周期——现在绿的跑就作数,之后的提交只在 `criterionStatus` 里记成 `staleAgainst` 这个事实)、**登记口**(周期的验收没全绿就不许登记新需求,请求直接丢掉——现在直接开成新车道)、**结题全绿**(关议题/合 MR/进 review、delivery 都要求全绿——现在只要每条 check 都真跑过一次)。三道墙的共同点是把“仪器测得的结果”当成“工作能不能继续”的开关;放宽后,红与旧都只是写进状态的事实,交给人去判断。
|
|
118
120
|
|
|
119
121
|
门槛是刻意压低的:门槛太高,团队会为了关掉一张讨论议题而绕过账本。记账动作只要不冒充“完成”(`review` / `delivery` 仍然要求每条 check 跑过一次,绿不绿写在状态里),就不该被未完成的需求挡住。
|
|
120
122
|
|
|
121
|
-
Host 侧实现:`lib/problem-gate.js`(从账本推导状态;`problemStatus` 与 `closureStatus` 读同一批运行,后者只数有 check 的标准)、`lib/problem-host.js`(`tools/result` 记录真实 commit
|
|
123
|
+
Host 侧实现:`lib/problem-gate.js`(从账本推导状态;`problemStatus` 与 `closureStatus` 读同一批运行,后者只数有 check 的标准)、`lib/problem-host.js`(`tools/result` 记录真实 commit/命令,并按分支与工作目录把证据归到对应车道;`tools/pre-execute` 只拦本轮需求自己的议题与合并)、`lib/lanes.js`(车道的阶段、绑定、焦点与合入队列)。账本只新增 `evidence` 表与需求的 `criteria[].check`;旧账本加载自动补空表,没有 check 的旧标准不会把议题永久锁死。
|
|
124
|
+
|
|
125
|
+
### 并行:一条需求一条车道,master 一次只合一条
|
|
126
|
+
|
|
127
|
+
团队不再让所有需求排成一条队。每条需求登记时就是一条**车道**:它有自己的阶段(`intake` → … → `retro`)、自己的决策和自己的证据,所以第二件事在第一条还在实现时就能开工。
|
|
128
|
+
|
|
129
|
+
- **车道是默认,不是例外。** `intake` 永不拒收,也不排队:新需求立刻从 `intake` 起步。`status` 的 `lanes` 列出所有在跑的车道(阶段、决策、分支、工作目录),`workflow` / `abandon` / `propose_decision` / `vote` / `decide` / `publish` 都可以带 `requirement_id` 指明落在哪一条。
|
|
130
|
+
- **各自一棵树。** 车道可以用 `lane` 动作绑上自己的分支和工作目录(先 `create_worktree` 建树,再 `lane` 绑定)。之后那个席位在树里跑命令、用 `infinity_git` 提交,Host 就能把观察到的东西记到正确的车道上——提交看分支,命令看工作目录。
|
|
131
|
+
- **证据只认自己的车道。** 一条车道的验收只看它自己的提交与运行:别的车道跑绿同一条命令,不算这一条通过。旧账本没有车道字段时,按登记时间把历史证据补记给当时在跑的那条车道。
|
|
132
|
+
- **串行的只有合入。** 交付把车道排进合入队列(`status.mergeQueue`),一条一条落到 `master`:这样几条车道能同时改代码,而 `master` 每一步都能构建。合入仍然要过四席评审,评审通过才轮到它。
|
|
133
|
+
- **阶段回到一条车道内。** 推进阶段读的是这条车道自己的阶段,不是全局指针;`state.stage` 仍保留,它是当前车道(focus)的镜像,给状态视图和老读者用。
|
|
134
|
+
|
|
135
|
+
车轮战和车队战的区别在这里:并行的是实现,不是合入。
|
|
122
136
|
|
|
123
137
|
### 缺件门禁(硬门禁)
|
|
124
138
|
|
|
@@ -152,15 +166,16 @@ Host 侧实现:`lib/problem-gate.js`(从账本推导状态;`problemStatus`
|
|
|
152
166
|
|
|
153
167
|
| action | 权限 | 说明 |
|
|
154
168
|
| --- | --- | --- |
|
|
155
|
-
| `status` | 成员 | 只读:默认只给核心字段(`activeDecision` 与 `problem`
|
|
169
|
+
| `status` | 成员 | 只读:默认只给核心字段(`activeDecision` 与 `problem` 排在最前,`lanes` 与 `mergeQueue` 始终在);历史表用 `include` 点名追加 |
|
|
156
170
|
| `roster` | 成员 | 只读:本地名册、角色、阶段 |
|
|
157
171
|
| `configure` | assistant / lead | 本地固定 GitFox 项目,不创建 issue |
|
|
158
172
|
| `intake` | assistant(或 lead) | 创建 GitFox 需求并登记;未 configure 则失败 |
|
|
159
|
-
| `abandon` | assistant / lead |
|
|
173
|
+
| `abandon` | assistant / lead | 关闭一条卡死的车道但不伪装完成:必须给 `reason`(`requirement_id` 省略时关当前车道);需求标记 `abandoned`、议题追加“未完成即关闭”的记录,别的车道照常推进;需求、证据、决策、会议全部保留 |
|
|
174
|
+
| `lane` | 成员(绑定只给 assistant / lead) | 只读一条车道的现状;带 `branch` / `worktree` / `mr` 时把这条车道绑到自己的分支、工作目录和合入请求(`mr` 写 `project#iid`,`requirement_id` 省略时用当前车道)。绑定决定证据归谁、合入门禁查哪条车道,所以只有掌计划的两个席位能写 |
|
|
160
175
|
| `meeting` | 成员 | 创建带纪要的 GitFox 会议;retro 只能 docs/hr |
|
|
161
176
|
| `propose_decision` | lead | 四席一致投票的决策。一个需求可以提任意多个决策——没有上限,因为每个新提案都要重新拿四票,投票本身才是那道闸 |
|
|
162
177
|
| `vote` / `decide` | 投票席位 / lead | 只在 `vote` 阶段;不可改票;缺票/reject/abstain 阻断 |
|
|
163
|
-
| `workflow` | 阶段 owner / lead |
|
|
178
|
+
| `workflow` | 阶段 owner / lead | 按 `requirement_id` 推进某一条车道(省略时为当前车道),一次一级;门禁读 Host 观察到的工作(commit、check 运行),不创建议题 |
|
|
164
179
|
| `reviews` | 成员 | 共享状态评审队列(合并 / 变基 / MR / issue),最新在前;`per_page`(默认 20,最大 100)+ 不透明 `cursor`,返回 `total` / `next_cursor` |
|
|
165
180
|
| `review` | qa、secure、lead | 必需席位投票;merge 需 qa + secure,issue 需 lead + qa |
|
|
166
181
|
| `define_role` | HR | 定义章程外的职位:`role_id` / `role_name` / `mandate` + `capabilities`(`shell`、`write` 的任意组合);不可定义 assistant/lead/hr,且不获得投票席位 |
|
package/client.js
CHANGED
|
@@ -690,9 +690,9 @@ const DATA_ZH = {
|
|
|
690
690
|
'principle_missing-is-a-task_title': '缺东西是任务,不是状态',
|
|
691
691
|
'principle_missing-is-a-task_text':
|
|
692
692
|
'缺少硬件、内核、依赖或远端对端,本身都不构成阻塞。为不存在的目标板做交叉编译或 JIT,从源码构建维护类依赖,先用回环地址测通传输,再去等对端。只要 Host 观察到确实尝试过,阻塞报告才会被接受;在那之前,唯一诚实的做法是去跑那次尝试。绕行式的运行会被 Host 识别并标出,绝不当作修复。',
|
|
693
|
-
'
|
|
694
|
-
'
|
|
695
|
-
'
|
|
693
|
+
'principle_parallel-lanes_title': '多车道并行,master 一次只合一条',
|
|
694
|
+
'principle_parallel-lanes_text':
|
|
695
|
+
'多个需求可以同时开工:每条车道有自己的阶段、自己的决策和自己的证据,所以第二件事不用等第一件交付才开始。立案时仍然要把整件事说全,一次列齐所有判据,因为把一件事切成好几条车道,等于每一刀都要再付一次投票和验收,还把真正的活藏了起来。保持串行的是合入:交付排进队列,一条一条落到 master,这样每一步 master 都能构建。证据只算自己那条车道的:提交写着它属于哪个分支,命令写着它在哪个工作目录里跑,别的车道的绿灯不能替这一条通过。',
|
|
696
696
|
'principle_fast-fail_title': '快速失败',
|
|
697
697
|
'principle_fast-fail_text':
|
|
698
698
|
'立即拒绝非法操作、缺失的跟踪、缺失的投票和被破坏的边界。绝不带着残缺或乐观的状态继续往下走。',
|
package/cordis.patch.yml
CHANGED
|
@@ -231,7 +231,11 @@
|
|
|
231
231
|
You are an implementation seat of the Infinity Fabric AI Team, powered by the {{model}}
|
|
232
232
|
model. Implement only inside the claim the Lead assigned and inside your role's write
|
|
233
233
|
scopes; write tests before production code; never approve your own work; drive git only
|
|
234
|
-
through infinity_git; a denied tool call is final.
|
|
234
|
+
through infinity_git; a denied tool call is final. When the Lead hands you a lane, call
|
|
235
|
+
create_worktree named after that requirement and report the branch and path back before
|
|
236
|
+
you change anything: that worktree is your lane's tree, and every command you run there,
|
|
237
|
+
plus every commit you make with infinity_git, belongs to it. Never work in another lane's
|
|
238
|
+
tree and never switch this session back to the project root mid-lane.
|
|
235
239
|
- id: agent-instructions
|
|
236
240
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
237
241
|
config:
|
package/index.js
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
} from './lib/team-runtime.js';
|
|
49
49
|
import { definePosition, requireCatalogRole, retirePosition } from './lib/positions.js';
|
|
50
50
|
import { assertNoBareBlocker } from './lib/blocker-gate.js';
|
|
51
|
+
import { laneAction, optionalLane, optionalRequirement } from './lib/lane-action.js';
|
|
52
|
+
import { currentDirectory } from './lib/session-dir.js';
|
|
51
53
|
import { amendCriterion } from './lib/criterion-amend.js';
|
|
52
54
|
import { createScopeInstaller } from './lib/scope-install.js';
|
|
53
55
|
import { createMemberTools } from './lib/member-tools.js';
|
|
@@ -103,6 +105,7 @@ const DELEGATED_ACTIONS = Object.freeze({
|
|
|
103
105
|
configure,
|
|
104
106
|
intake,
|
|
105
107
|
abandon,
|
|
108
|
+
lane: laneAction,
|
|
106
109
|
amend,
|
|
107
110
|
meeting,
|
|
108
111
|
propose_decision: propose,
|
|
@@ -114,13 +117,14 @@ const DELEGATED_ACTIONS = Object.freeze({
|
|
|
114
117
|
define_role: definePosition,
|
|
115
118
|
retire_role: retirePosition,
|
|
116
119
|
attention: attentionAction,
|
|
117
|
-
publish: (core, caller) =>
|
|
120
|
+
publish: (core, caller, args) =>
|
|
118
121
|
publishWorkRecord({
|
|
119
122
|
store: core.store,
|
|
120
123
|
loadState: loadedState,
|
|
121
124
|
tracked,
|
|
122
125
|
ctx: core.ctx,
|
|
123
126
|
caller,
|
|
127
|
+
...optionalRequirement(args),
|
|
124
128
|
}),
|
|
125
129
|
});
|
|
126
130
|
|
|
@@ -503,6 +507,7 @@ async function intake(core, caller, args) {
|
|
|
503
507
|
*/
|
|
504
508
|
async function abandon(core, caller, args) {
|
|
505
509
|
const reason = requireString(args, 'reason', 'abandon');
|
|
510
|
+
const requirementId = optionalId(args, 'requirement_id');
|
|
506
511
|
const abandoned = await core.store.mutate(async (draft, now) => {
|
|
507
512
|
requireSeeded(draft);
|
|
508
513
|
const roles = callerRoles(draft, caller);
|
|
@@ -511,6 +516,7 @@ async function abandon(core, caller, args) {
|
|
|
511
516
|
// half state: the issue is only touched once the ledger accepts the close.
|
|
512
517
|
const preview = abandonCycle(structuredClone(draft), {
|
|
513
518
|
reason,
|
|
519
|
+
...(requirementId === undefined ? {} : { requirementId }),
|
|
514
520
|
roles,
|
|
515
521
|
member: caller.member,
|
|
516
522
|
now,
|
|
@@ -534,6 +540,7 @@ async function abandon(core, caller, args) {
|
|
|
534
540
|
);
|
|
535
541
|
return abandonCycle(draft, {
|
|
536
542
|
reason,
|
|
543
|
+
...(requirementId === undefined ? {} : { requirementId }),
|
|
537
544
|
roles,
|
|
538
545
|
member: caller.member,
|
|
539
546
|
now,
|
|
@@ -575,6 +582,7 @@ async function amend(core, caller, args) {
|
|
|
575
582
|
return amendCriterion(draft, {
|
|
576
583
|
index,
|
|
577
584
|
check,
|
|
585
|
+
...optionalRequirement(args),
|
|
578
586
|
...(reason === undefined ? {} : { reason }),
|
|
579
587
|
roles: callerRoles(draft, caller),
|
|
580
588
|
member: caller.member,
|
|
@@ -610,12 +618,14 @@ async function meeting(core, caller, args) {
|
|
|
610
618
|
const record = await core.store.mutate(async (draft, now) => {
|
|
611
619
|
requireSeeded(draft);
|
|
612
620
|
const roles = callerRoles(draft, caller);
|
|
621
|
+
const lane = optionalLane(draft, args);
|
|
613
622
|
recordMeeting(structuredClone(draft), {
|
|
614
623
|
topic,
|
|
615
624
|
kind,
|
|
616
625
|
attendees,
|
|
617
626
|
minutes,
|
|
618
627
|
tracking: null,
|
|
628
|
+
requirementId: lane?.id,
|
|
619
629
|
member: caller.member,
|
|
620
630
|
roles,
|
|
621
631
|
now,
|
|
@@ -798,6 +808,7 @@ async function decide(core, caller, args) {
|
|
|
798
808
|
*/
|
|
799
809
|
async function workflow(core, caller, args) {
|
|
800
810
|
const target = requireString(args, 'stage', 'workflow');
|
|
811
|
+
const requirementId = optionalId(args, 'requirement_id');
|
|
801
812
|
if (!STAGE_IDS.includes(target) || target === 'intake') {
|
|
802
813
|
throw new TeamError(
|
|
803
814
|
`stage must be one of: ${STAGE_IDS.filter((id) => id !== 'intake').join(', ')}`,
|
|
@@ -815,6 +826,7 @@ async function workflow(core, caller, args) {
|
|
|
815
826
|
const roles = callerRoles(draft, caller);
|
|
816
827
|
return advanceWorkflow(draft, {
|
|
817
828
|
target,
|
|
829
|
+
...(requirementId === undefined ? {} : { requirementId }),
|
|
818
830
|
...(note === undefined ? {} : { note }),
|
|
819
831
|
tracking: null,
|
|
820
832
|
roles,
|
|
@@ -825,11 +837,25 @@ async function workflow(core, caller, args) {
|
|
|
825
837
|
return {
|
|
826
838
|
action: 'workflow',
|
|
827
839
|
stage: target,
|
|
840
|
+
requirementId: entry.requirementId,
|
|
828
841
|
workflow: entry,
|
|
829
842
|
problem: problemStatus(await core.store.load()),
|
|
830
843
|
};
|
|
831
844
|
}
|
|
832
845
|
|
|
846
|
+
/**
|
|
847
|
+
* Read one optional id argument, trimmed.
|
|
848
|
+
*
|
|
849
|
+
* @param {Record<string, unknown>} args - tool arguments.
|
|
850
|
+
* @param {string} key - field name.
|
|
851
|
+
* @returns {string | undefined} the id.
|
|
852
|
+
*/
|
|
853
|
+
function optionalId(args, key) {
|
|
854
|
+
const value = args[key];
|
|
855
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
833
859
|
/**
|
|
834
860
|
* Local HR enrollment. No GitFox issue.
|
|
835
861
|
*
|
|
@@ -1694,6 +1720,7 @@ export function apply(ctx, config = {}) {
|
|
|
1694
1720
|
loadState: loadedState,
|
|
1695
1721
|
memberOf: (state, agent) => memberFor(core, state, agent),
|
|
1696
1722
|
isTeamAgent: (agent) => isTeamModeAgent(ctx, agent),
|
|
1723
|
+
cwdOf: (agent) => currentDirectory(ctx, agent),
|
|
1697
1724
|
withTeam: (agent, run) => core.withTeam(agent, run),
|
|
1698
1725
|
});
|
|
1699
1726
|
handles.workRecords = createWorkRecordController({
|
|
@@ -1811,6 +1838,7 @@ export const inject = [
|
|
|
1811
1838
|
'userQuestions',
|
|
1812
1839
|
'webServer',
|
|
1813
1840
|
'sessionProjections',
|
|
1841
|
+
'workingDirectory',
|
|
1814
1842
|
];
|
|
1815
1843
|
/** Re-exported for tests. */
|
|
1816
1844
|
export { TEAM_MODE_PROTOCOL };
|
package/lib/criterion-amend.js
CHANGED
|
@@ -50,7 +50,7 @@ function requireText(value, field, max) {
|
|
|
50
50
|
* Replace the command behind one acceptance criterion, in place.
|
|
51
51
|
*
|
|
52
52
|
* @param {object} state - ledger state.
|
|
53
|
-
* @param {{ index: number, check: string, reason?: string, roles: string[], member: string, now: string }} input - amendment.
|
|
53
|
+
* @param {{ index: number, check: string, reason?: string, requirementId?: string, roles: string[], member: string, now: string }} input - amendment.
|
|
54
54
|
* @returns {{ requirement: object, amendment: object }} the amended requirement and the record.
|
|
55
55
|
*/
|
|
56
56
|
export function amendCriterion(state, input) {
|
|
@@ -62,21 +62,23 @@ export function amendCriterion(state, input) {
|
|
|
62
62
|
{ status: 403 },
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
);
|
|
65
|
+
const wanted = input.requirementId ?? state.activeRequirementId;
|
|
66
|
+
const requirement = (state.requirements ?? []).find((candidate) => candidate.id === wanted);
|
|
68
67
|
if (requirement === undefined) {
|
|
69
68
|
throw new TeamError(
|
|
70
|
-
|
|
69
|
+
input.requirementId === undefined
|
|
70
|
+
? 'there is no active requirement to amend; open intake first'
|
|
71
|
+
: `unknown requirement "${input.requirementId}"; read "status" for the lanes`,
|
|
71
72
|
'no-active-requirement',
|
|
72
73
|
);
|
|
73
74
|
}
|
|
74
75
|
// Before review the check is still a hypothesis about how to watch the work.
|
|
75
76
|
// Once review or delivery starts, replacing the command would be grading a
|
|
76
77
|
// fixed answer on a new question, so a different measurement needs a new cycle.
|
|
77
|
-
|
|
78
|
+
const stage = inputStage(state, requirement);
|
|
79
|
+
if (['review', 'delivery'].includes(stage)) {
|
|
78
80
|
throw new TeamError(
|
|
79
|
-
`
|
|
81
|
+
`requirement ${requirement.id} is already at "${stage}"; the checks it is being reviewed against are closed — a different measurement needs a new cycle`,
|
|
80
82
|
'cycle-in-flight',
|
|
81
83
|
{ status: 409 },
|
|
82
84
|
);
|
|
@@ -107,3 +109,17 @@ export function amendCriterion(state, input) {
|
|
|
107
109
|
state.updatedAt = input.now;
|
|
108
110
|
return { requirement, amendment };
|
|
109
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The stage of the lane being amended.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} state - ledger state.
|
|
117
|
+
* @param {object} requirement - the lane.
|
|
118
|
+
* @returns {string} stage id.
|
|
119
|
+
*/
|
|
120
|
+
function inputStage(state, requirement) {
|
|
121
|
+
if (typeof requirement.stage === 'string') return requirement.stage;
|
|
122
|
+
const focused = state.activeRequirementId === requirement.id;
|
|
123
|
+
if (focused && typeof state.stage === 'string') return state.stage;
|
|
124
|
+
return 'intake';
|
|
125
|
+
}
|
package/lib/cycle-close.js
CHANGED
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* open.
|
|
2
|
+
* Closing one lane, and what an abandoned attempt leaves behind.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* A second requirement beside a running cycle used to be refused outright. That
|
|
10
|
-
* refusal lost the ask: a team whose cycle was stuck at a red or stale check
|
|
11
|
-
* could not take the next piece of work at all. Intake is recorded now and
|
|
12
|
-
* waits in a queue (`promoteQueued` below), so one cycle still runs at a
|
|
13
|
-
* time without anything being turned away.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Whether the ledger is inside a cycle that has not ended yet.
|
|
18
|
-
*
|
|
19
|
-
* "Ended" is not the same as "all green": a cycle ends by closing (abandon) or
|
|
20
|
-
* by a new requirement opening after retro. Every state in between is in
|
|
21
|
-
* flight, whatever its stage and however red its checks are.
|
|
22
|
-
*
|
|
23
|
-
* @param {object} state - ledger state.
|
|
24
|
-
* @returns {boolean} true while a cycle owns the stage.
|
|
25
|
-
*/
|
|
26
|
-
export function cycleInFlight(state) {
|
|
27
|
-
const active = (state.requirements ?? []).find(
|
|
28
|
-
(candidate) => candidate.id === state.activeRequirementId,
|
|
29
|
-
);
|
|
30
|
-
if (active === undefined) return false;
|
|
31
|
-
if (state.stage === 'retro') return false;
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Give the stage to the oldest requirement waiting in the queue.
|
|
4
|
+
* Lanes run side by side, so closing one is a local act: it ends that lane's
|
|
5
|
+
* reviews and attentions and leaves every other lane exactly where it stood.
|
|
6
|
+
* Nothing here decides who runs next — that is the focus, not a queue.
|
|
37
7
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* requirement keeps its record and its criteria, activates the moment the
|
|
42
|
-
* stage is free, and starts at intake like any other.
|
|
43
|
-
*
|
|
44
|
-
* @param {object} state - ledger state.
|
|
45
|
-
* @param {{ at: string, by: string }} input - when and who freed the stage.
|
|
46
|
-
* @returns {object | undefined} the requirement that took the stage.
|
|
8
|
+
* Leaving an abandoned lane's pending reviews behind turns it into a permanent
|
|
9
|
+
* backlog that re-arms the team goal every round; the same is true for an open
|
|
10
|
+
* attention nobody can answer once the lane is gone.
|
|
47
11
|
*/
|
|
48
|
-
export function promoteQueued(state, input) {
|
|
49
|
-
if (cycleInFlight(state)) return undefined;
|
|
50
|
-
const next = (state.requirements ?? []).find((candidate) => candidate.status === 'queued');
|
|
51
|
-
if (next === undefined) return undefined;
|
|
52
|
-
delete next.status;
|
|
53
|
-
delete next.queuedAt;
|
|
54
|
-
next.activatedAt = input.at;
|
|
55
|
-
next.activatedBy = input.by;
|
|
56
|
-
state.activeRequirementId = next.id;
|
|
57
|
-
state.activeDecisionId = null;
|
|
58
|
-
state.stage = 'intake';
|
|
59
|
-
state.updatedAt = input.at;
|
|
60
|
-
state.workflow.push({
|
|
61
|
-
stage: 'intake',
|
|
62
|
-
at: input.at,
|
|
63
|
-
by: input.by,
|
|
64
|
-
note: `${next.id} takes the stage from the queue`,
|
|
65
|
-
});
|
|
66
|
-
return next;
|
|
67
|
-
}
|
|
68
12
|
|
|
69
13
|
/**
|
|
70
14
|
* How many abandoned attempts share one request.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lane` action: read one lane, and optionally bind it to the branch and
|
|
3
|
+
* directory its work lives in.
|
|
4
|
+
*
|
|
5
|
+
* This is how a Lead hands a lane its own tree. The worker then runs every
|
|
6
|
+
* command inside that directory, and the Host attributes what it observes there
|
|
7
|
+
* to this lane. Without the binding, two workers editing at once would both
|
|
8
|
+
* look like the focused lane. Binding is limited to the assistant and the Lead:
|
|
9
|
+
* the attribution has to come from the seats that own the plan, not from the
|
|
10
|
+
* seat whose work is being attributed.
|
|
11
|
+
*/
|
|
12
|
+
import { bindLane, laneFor, laneSummaries } from './lanes.js';
|
|
13
|
+
import { requireSeeded } from './ledger-guards.js';
|
|
14
|
+
import { assertCallerRole } from './team-core.js';
|
|
15
|
+
import { TeamError } from './team-error.js';
|
|
16
|
+
|
|
17
|
+
/** Only the seats that own the plan may point a lane at a branch and a tree. */
|
|
18
|
+
const BINDING_ROLES = ['assistant', 'lead'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read or bind one lane.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} core - plugin state.
|
|
24
|
+
* @param {{ member: string }} caller - caller.
|
|
25
|
+
* @param {Record<string, unknown>} args - tool arguments.
|
|
26
|
+
* @returns {Promise<object>} canonical result.
|
|
27
|
+
*/
|
|
28
|
+
export async function laneAction(core, caller, args) {
|
|
29
|
+
const requirementId = stringArg(args, 'requirement_id');
|
|
30
|
+
const branch = stringArg(args, 'branch');
|
|
31
|
+
const worktree = stringArg(args, 'worktree');
|
|
32
|
+
const mr = stringArg(args, 'mr');
|
|
33
|
+
const row = await core.store.mutate((draft, now) => {
|
|
34
|
+
requireSeeded(draft);
|
|
35
|
+
if (branch === undefined && worktree === undefined && mr === undefined) {
|
|
36
|
+
return readLane(draft, requirementId);
|
|
37
|
+
}
|
|
38
|
+
assertCallerRole(draft, caller, BINDING_ROLES, 'lane');
|
|
39
|
+
return bindLane(draft, {
|
|
40
|
+
...(requirementId === undefined ? {} : { requirementId }),
|
|
41
|
+
...(branch === undefined ? {} : { branch }),
|
|
42
|
+
...(worktree === undefined ? {} : { worktree }),
|
|
43
|
+
...(mr === undefined ? {} : { mr }),
|
|
44
|
+
member: caller.member,
|
|
45
|
+
now,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return { action: 'lane', lane: row };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One lane row, without changing anything.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} state - ledger state.
|
|
55
|
+
* @param {string | undefined} requirementId - named lane, defaulting to the focus.
|
|
56
|
+
* @returns {object} lane row.
|
|
57
|
+
* @throws {TeamError} when no such lane is open.
|
|
58
|
+
*/
|
|
59
|
+
function readLane(state, requirementId) {
|
|
60
|
+
const wanted = requirementId ?? state.activeRequirementId;
|
|
61
|
+
const row = laneSummaries(state).find((lane) => lane.id === wanted);
|
|
62
|
+
if (row === undefined) {
|
|
63
|
+
throw new TeamError(
|
|
64
|
+
`no open lane ${requirementId ?? 'in focus'}; read "status" for the lanes`,
|
|
65
|
+
'no-active-requirement',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return row;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read one optional string argument, trimmed.
|
|
73
|
+
*
|
|
74
|
+
* @param {Record<string, unknown>} args - tool arguments.
|
|
75
|
+
* @param {string} key - field name.
|
|
76
|
+
* @returns {string | undefined} the value.
|
|
77
|
+
*/
|
|
78
|
+
function stringArg(args, key) {
|
|
79
|
+
const value = args[key];
|
|
80
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Narrow the "requirement_id" argument to a publish call.
|
|
85
|
+
*
|
|
86
|
+
* @param {Record<string, unknown>} args - tool arguments.
|
|
87
|
+
* @returns {{ requirementId?: string }} options.
|
|
88
|
+
*/
|
|
89
|
+
export function optionalRequirement(args) {
|
|
90
|
+
const requirementId = stringArg(args, 'requirement_id');
|
|
91
|
+
return requirementId === undefined ? {} : { requirementId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The lane an action names, or the focused lane when it names none.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} state - ledger state.
|
|
98
|
+
* @param {Record<string, unknown>} args - tool arguments.
|
|
99
|
+
* @returns {object | undefined} the lane, or undefined when nothing is open.
|
|
100
|
+
*/
|
|
101
|
+
export function optionalLane(state, args) {
|
|
102
|
+
const requirementId = stringArg(args, 'requirement_id');
|
|
103
|
+
if (requirementId !== undefined) return laneFor(state, requirementId);
|
|
104
|
+
const focused = state.activeRequirementId;
|
|
105
|
+
return focused === null || focused === undefined ? undefined : laneFor(state, focused);
|
|
106
|
+
}
|