@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/lib/team-core.js
CHANGED
|
@@ -20,18 +20,38 @@ import {
|
|
|
20
20
|
} from './team-model.js';
|
|
21
21
|
import { assertStageProof, parseCriteria, problemStatus } from './problem-gate.js';
|
|
22
22
|
import { TeamError } from './team-error.js';
|
|
23
|
+
import { optionalBounded, requireBounded, requireSeeded } from './ledger-guards.js';
|
|
23
24
|
import {
|
|
24
25
|
assertReopenAllowed,
|
|
25
|
-
cycleInFlight,
|
|
26
26
|
expireCycleAttentions,
|
|
27
27
|
expireCycleReviews,
|
|
28
28
|
priorAttempts,
|
|
29
|
-
promoteQueued,
|
|
30
29
|
} from './cycle-close.js';
|
|
31
30
|
import { boundedDecision, boundedProblem, stageName, tally, watchdogOf } from './status-view.js';
|
|
31
|
+
import {
|
|
32
|
+
backfillLanes,
|
|
33
|
+
bindLane,
|
|
34
|
+
dropFromMergeQueue,
|
|
35
|
+
enqueueMerge,
|
|
36
|
+
focusLane,
|
|
37
|
+
focusOldestRunning,
|
|
38
|
+
focusedLane,
|
|
39
|
+
laneFor,
|
|
40
|
+
laneInFlight,
|
|
41
|
+
laneIsOpen,
|
|
42
|
+
laneStage,
|
|
43
|
+
laneSummaries,
|
|
44
|
+
mergeQueueRows,
|
|
45
|
+
openLanes,
|
|
46
|
+
requirementIn,
|
|
47
|
+
} from './lanes.js';
|
|
32
48
|
|
|
33
49
|
export { tally };
|
|
34
50
|
|
|
51
|
+
export { bindLane, focusLane, focusedLane, laneFor, laneInFlight, laneIsOpen, laneStage, laneSummaries, openLanes };
|
|
52
|
+
|
|
53
|
+
export { requireBounded, requireSeeded };
|
|
54
|
+
|
|
35
55
|
export { TeamError };
|
|
36
56
|
|
|
37
57
|
/** Role ids that are always seeded, in roster order. */
|
|
@@ -83,31 +103,6 @@ function bounded(value, field, max) {
|
|
|
83
103
|
return text;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
/**
|
|
87
|
-
* Trim an optional string and enforce its bound.
|
|
88
|
-
*
|
|
89
|
-
* @param {unknown} value - candidate text.
|
|
90
|
-
* @param {string} field - field name for the error.
|
|
91
|
-
* @param {number} max - maximum accepted characters.
|
|
92
|
-
* @returns {string | undefined} trimmed text or undefined.
|
|
93
|
-
*/
|
|
94
|
-
function optionalBounded(value, field, max) {
|
|
95
|
-
if (value === undefined || value === null) return undefined;
|
|
96
|
-
if (typeof value !== 'string') {
|
|
97
|
-
throw new TeamError(`"${field}" must be a string`, 'invalid-arguments', { status: 400 });
|
|
98
|
-
}
|
|
99
|
-
const text = value.trim();
|
|
100
|
-
if (text === '') return undefined;
|
|
101
|
-
if (text.length > max) {
|
|
102
|
-
throw new TeamError(
|
|
103
|
-
`"${field}" exceeds ${max} characters`,
|
|
104
|
-
'invalid-arguments',
|
|
105
|
-
{ status: 400 },
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
return text;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
106
|
/**
|
|
112
107
|
* Create a fresh, unseeded ledger.
|
|
113
108
|
*
|
|
@@ -141,6 +136,7 @@ export function createTeamState(now = new Date().toISOString()) {
|
|
|
141
136
|
workflow: [],
|
|
142
137
|
reviews: [],
|
|
143
138
|
evidence: [],
|
|
139
|
+
mergeQueue: [],
|
|
144
140
|
counters: {
|
|
145
141
|
identity: 0,
|
|
146
142
|
requirement: 0,
|
|
@@ -153,22 +149,6 @@ export function createTeamState(now = new Date().toISOString()) {
|
|
|
153
149
|
};
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
/**
|
|
157
|
-
* Trim a required string and enforce its bound.
|
|
158
|
-
*
|
|
159
|
-
* @param {unknown} value - candidate text.
|
|
160
|
-
* @param {string} field - field name for the error.
|
|
161
|
-
* @param {number} max - maximum accepted characters.
|
|
162
|
-
* @returns {string} trimmed text.
|
|
163
|
-
*/
|
|
164
|
-
function requireBounded(value, field, max) {
|
|
165
|
-
const text = optionalBounded(value, field, max);
|
|
166
|
-
if (text === undefined) {
|
|
167
|
-
throw new TeamError(`"${field}" is required`, 'invalid-arguments', { status: 400 });
|
|
168
|
-
}
|
|
169
|
-
return text;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
152
|
/**
|
|
173
153
|
* Format the next sequence id for one record kind.
|
|
174
154
|
*
|
|
@@ -374,15 +354,6 @@ export function retireRole(state, { roleId, by, now, reason }) {
|
|
|
374
354
|
* @param {object} state - ledger state.
|
|
375
355
|
* @returns {void}
|
|
376
356
|
*/
|
|
377
|
-
export function requireSeeded(state) {
|
|
378
|
-
if (state.seeded !== true) {
|
|
379
|
-
throw new TeamError(
|
|
380
|
-
'The team is not seeded. The Lead must call infinity_team action "bootstrap" with the GitFox project first.',
|
|
381
|
-
'not-seeded',
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
357
|
/**
|
|
387
358
|
* Resolve the long-lived identity owned by one Agent member name.
|
|
388
359
|
*
|
|
@@ -685,12 +656,16 @@ export function retireIdentity(state, { employeeId, by, now, reason }) {
|
|
|
685
656
|
return identity;
|
|
686
657
|
}
|
|
687
658
|
|
|
659
|
+
|
|
688
660
|
/**
|
|
689
|
-
* Open one tracked requirement.
|
|
661
|
+
* Open one tracked requirement as its own lane.
|
|
690
662
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
663
|
+
* Every requirement owns a stage pointer, so intake no longer waits behind a
|
|
664
|
+
* running cycle: opening work beside running work is what makes the team able
|
|
665
|
+
* to use more than one pair of hands. What used to be the cycle's exclusivity
|
|
666
|
+
* lives where it belongs — in the vote (one approved decision per lane) and in
|
|
667
|
+
* the merge queue, which keeps `master` buildable while lanes land one at a
|
|
668
|
+
* time.
|
|
694
669
|
*
|
|
695
670
|
* @param {object} state - ledger state.
|
|
696
671
|
* @param {object} input - title, request, acceptance, checks, tracking, member, roles, now.
|
|
@@ -719,43 +694,26 @@ export function recordRequirement(state, input) {
|
|
|
719
694
|
acceptance,
|
|
720
695
|
criteria,
|
|
721
696
|
...(boundaries === undefined ? {} : { boundaries }),
|
|
697
|
+
status: 'open',
|
|
698
|
+
stage: 'intake',
|
|
699
|
+
decisionId: null,
|
|
722
700
|
tracking: input.tracking,
|
|
723
701
|
openedBy: input.member,
|
|
724
702
|
createdAt: input.now,
|
|
725
703
|
...(reopens === 0 ? {} : { reopens, reopenReason }),
|
|
726
704
|
};
|
|
727
705
|
state.requirements.push(requirement);
|
|
728
|
-
// A
|
|
729
|
-
//
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
at: input.now,
|
|
740
|
-
by: input.member,
|
|
741
|
-
note: `${requirement.id} queued behind ${older?.id ?? state.activeRequirementId}; it takes the stage when the cycle ends`,
|
|
742
|
-
});
|
|
743
|
-
promoteQueued(state, { at: input.now, by: input.member });
|
|
744
|
-
return requirement;
|
|
745
|
-
}
|
|
746
|
-
// A new requirement after the final stage starts the next cycle instead of
|
|
747
|
-
// leaving the ledger stuck at "retro" forever.
|
|
748
|
-
if (state.stage === 'retro') {
|
|
749
|
-
state.stage = 'intake';
|
|
750
|
-
state.workflow.push({
|
|
751
|
-
stage: 'intake',
|
|
752
|
-
at: input.now,
|
|
753
|
-
by: input.member,
|
|
754
|
-
note: `New cycle opened by ${requirement.id}`,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
state.activeRequirementId = requirement.id;
|
|
758
|
-
state.activeDecisionId = null;
|
|
706
|
+
// A lane takes the focus only when nothing else is running: opening work
|
|
707
|
+
// must not move the reported state out from under the seat reading it.
|
|
708
|
+
const focused = focusedLane(state);
|
|
709
|
+
if (focused === undefined || !laneInFlight(state, focused)) focusLane(state, requirement.id);
|
|
710
|
+
state.workflow.push({
|
|
711
|
+
stage: 'intake',
|
|
712
|
+
at: input.now,
|
|
713
|
+
by: input.member,
|
|
714
|
+
requirementId: requirement.id,
|
|
715
|
+
note: `${requirement.id} opened as a lane at intake`,
|
|
716
|
+
});
|
|
759
717
|
state.updatedAt = input.now;
|
|
760
718
|
return requirement;
|
|
761
719
|
}
|
|
@@ -786,39 +744,44 @@ export function abandonCycle(state, input) {
|
|
|
786
744
|
{ status: 403 },
|
|
787
745
|
);
|
|
788
746
|
}
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
);
|
|
747
|
+
const wanted = input.requirementId ?? state.activeRequirementId;
|
|
748
|
+
const requirement = (state.requirements ?? []).find((candidate) => candidate.id === wanted);
|
|
792
749
|
if (requirement === undefined) {
|
|
793
750
|
throw new TeamError(
|
|
794
|
-
'there is no
|
|
751
|
+
'there is no open lane to close; pass "requirement_id" to name the lane, or the ledger has no work in flight',
|
|
795
752
|
'no-active-requirement',
|
|
796
753
|
);
|
|
797
754
|
}
|
|
755
|
+
if (!laneIsOpen(requirement)) {
|
|
756
|
+
throw new TeamError(
|
|
757
|
+
`requirement ${requirement.id} is already ${requirement.status ?? 'closed'}`,
|
|
758
|
+
'lane-closed',
|
|
759
|
+
{ status: 409 },
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const stage = laneStage(state, requirement);
|
|
798
763
|
const reason = requireBounded(input.reason, 'reason', 2_000);
|
|
799
764
|
requirement.status = 'abandoned';
|
|
800
765
|
requirement.abandonedAt = input.now;
|
|
801
766
|
requirement.abandonedBy = input.member;
|
|
802
767
|
requirement.abandonReason = reason;
|
|
803
|
-
requirement.abandonedStage =
|
|
804
|
-
requirement.abandonedDecisionId = state.activeDecisionId ?? null;
|
|
805
|
-
// Closing the
|
|
768
|
+
requirement.abandonedStage = stage;
|
|
769
|
+
requirement.abandonedDecisionId = requirement.decisionId ?? state.activeDecisionId ?? null;
|
|
770
|
+
// Closing the lane also closes the work that belonged to it. Leaving its
|
|
806
771
|
// pending reviews behind turns an abandoned cycle into a permanent backlog
|
|
807
772
|
// that re-arms the team goal every round.
|
|
808
773
|
expireCycleReviews(state, requirement, input.now);
|
|
809
774
|
expireCycleAttentions(state, requirement, input.now);
|
|
810
|
-
state.activeRequirementId = null;
|
|
811
|
-
state.activeDecisionId = null;
|
|
812
|
-
state.stage = 'intake';
|
|
813
775
|
state.workflow.push({
|
|
814
|
-
stage
|
|
776
|
+
stage,
|
|
815
777
|
at: input.now,
|
|
816
778
|
by: input.member,
|
|
817
|
-
|
|
779
|
+
requirementId: requirement.id,
|
|
780
|
+
note: `Lane closed without completion: ${requirement.id} — ${reason}`,
|
|
818
781
|
});
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
782
|
+
// Only the lane that held the focus moves the reported state; the other
|
|
783
|
+
// lanes keep running, which is the whole point of having them.
|
|
784
|
+
if (state.activeRequirementId === requirement.id) focusOldestRunning(state);
|
|
822
785
|
state.updatedAt = input.now;
|
|
823
786
|
return requirement;
|
|
824
787
|
}
|
|
@@ -870,7 +833,7 @@ export function recordMeeting(state, input) {
|
|
|
870
833
|
attendees: [...input.attendees],
|
|
871
834
|
minutes,
|
|
872
835
|
tracking: input.tracking,
|
|
873
|
-
requirementId: state.activeRequirementId ?? null,
|
|
836
|
+
requirementId: input.requirementId ?? state.activeRequirementId ?? null,
|
|
874
837
|
openedBy: input.member,
|
|
875
838
|
createdAt: input.now,
|
|
876
839
|
};
|
|
@@ -925,8 +888,11 @@ export function proposeDecision(state, input) {
|
|
|
925
888
|
decidedBy: null,
|
|
926
889
|
};
|
|
927
890
|
state.decisions.push(decision);
|
|
928
|
-
|
|
929
|
-
|
|
891
|
+
// The decision belongs to one lane; recording it there is what lets the vote
|
|
892
|
+
// stage read the right proposal when several lanes are in flight.
|
|
893
|
+
requirement.decisionId = decision.id;
|
|
894
|
+
if (focusedLane(state) === undefined) focusLane(state, requirement.id);
|
|
895
|
+
if (state.activeRequirementId === requirement.id) state.activeDecisionId = decision.id;
|
|
930
896
|
state.updatedAt = input.now;
|
|
931
897
|
return decision;
|
|
932
898
|
}
|
|
@@ -974,10 +940,12 @@ export function castVote(state, input) {
|
|
|
974
940
|
);
|
|
975
941
|
}
|
|
976
942
|
// Votes belong to the vote stage: it is where the decision was opened and
|
|
977
|
-
// the only stage whose gate reads an open decision.
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
943
|
+
// the only stage whose gate reads an open decision. The stage is the one the
|
|
944
|
+
// decision's own lane stands at, so a vote cannot be cast on a lane that is
|
|
945
|
+
// still planning while another lane votes.
|
|
946
|
+
const lane = decisionLane(state, decision);
|
|
947
|
+
const stage = laneStage(state, lane);
|
|
948
|
+
if (stage !== 'vote') throw new TeamError(`votes are cast in the vote stage; requirement ${decision.requirementId} is in "${stage}" — advance that lane to "vote" first`, 'workflow-order', { status: 409 });
|
|
981
949
|
if (!VOTES.includes(input.vote)) {
|
|
982
950
|
throw new TeamError(`vote must be one of: ${VOTES.join(', ')}`, 'invalid-vote', { status: 400 });
|
|
983
951
|
}
|
|
@@ -1045,10 +1013,12 @@ export function finalizeDecision(state, input) {
|
|
|
1045
1013
|
if (decision.status === 'approved') {
|
|
1046
1014
|
throw new TeamError(`decision ${decision.id} is already approved`, 'decision-approved');
|
|
1047
1015
|
}
|
|
1048
|
-
// A decision takes effect inside the vote stage
|
|
1016
|
+
// A decision takes effect inside the vote stage of the lane that owns it.
|
|
1049
1017
|
// Finalizing it earlier left an approved decision with the vote stage still
|
|
1050
1018
|
// ahead: the gate could never open again and the cycle looked stuck.
|
|
1051
|
-
|
|
1019
|
+
const lane = decisionLane(state, decision);
|
|
1020
|
+
const stage = laneStage(state, lane);
|
|
1021
|
+
if (stage !== 'vote') throw new TeamError(`a decision is finalized in the vote stage; requirement ${decision.requirementId} is in "${stage}" — advance that lane to "vote" first`, 'workflow-order', { status: 409 });
|
|
1052
1022
|
assertUnanimous(decision);
|
|
1053
1023
|
decision.status = 'approved';
|
|
1054
1024
|
decision.decidedAt = input.now;
|
|
@@ -1226,21 +1196,34 @@ export function castReviewVerdict(state, input) {
|
|
|
1226
1196
|
}
|
|
1227
1197
|
|
|
1228
1198
|
/**
|
|
1229
|
-
* Validate one forward workflow transition and apply it.
|
|
1199
|
+
* Validate one forward workflow transition on one lane and apply it.
|
|
1200
|
+
*
|
|
1201
|
+
* The lane is named by `requirement_id`; without it the focused lane moves,
|
|
1202
|
+
* which keeps a single-lane caller working unchanged. Several lanes may stand
|
|
1203
|
+
* at different stages at once — that is what lets the team run intake, a vote
|
|
1204
|
+
* and an implementation side by side instead of queueing them.
|
|
1230
1205
|
*
|
|
1231
1206
|
* @param {object} state - ledger state.
|
|
1232
|
-
* @param {{ target: string, note?: string, tracking: object, roles: string[], member: string, now: string }} input - transition.
|
|
1207
|
+
* @param {{ target: string, requirementId?: string, note?: string, tracking: object, roles: string[], member: string, now: string }} input - transition.
|
|
1233
1208
|
* @returns {object} the appended workflow entry.
|
|
1234
1209
|
*/
|
|
1235
1210
|
export function advanceWorkflow(state, input) {
|
|
1236
1211
|
requireSeeded(state);
|
|
1237
|
-
const
|
|
1212
|
+
const requirement = requirementIn(state, input.requirementId);
|
|
1213
|
+
const stage = laneStage(state, requirement);
|
|
1214
|
+
if (!laneIsOpen(requirement)) {
|
|
1215
|
+
throw new TeamError(
|
|
1216
|
+
`requirement ${requirement.id} is ${requirement.status ?? 'closed'}; nothing is left to advance — open a new lane with intake`,
|
|
1217
|
+
'workflow-complete',
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
const expected = nextStage(stage);
|
|
1238
1221
|
if (expected === undefined) {
|
|
1239
|
-
throw new TeamError(`
|
|
1222
|
+
throw new TeamError(`requirement ${requirement.id} is already at its final stage "${stage}"`, 'workflow-complete');
|
|
1240
1223
|
}
|
|
1241
1224
|
if (input.target !== expected) {
|
|
1242
1225
|
throw new TeamError(
|
|
1243
|
-
`workflow must advance exactly one stage: next is "${expected}", not "${input.target}"`,
|
|
1226
|
+
`workflow must advance exactly one stage: ${requirement.id} stands at "${stage}", so next is "${expected}", not "${input.target}"`,
|
|
1244
1227
|
'workflow-order',
|
|
1245
1228
|
);
|
|
1246
1229
|
}
|
|
@@ -1253,22 +1236,46 @@ export function advanceWorkflow(state, input) {
|
|
|
1253
1236
|
);
|
|
1254
1237
|
}
|
|
1255
1238
|
const note = optionalBounded(input.note, 'note', 4000);
|
|
1256
|
-
gate(state, input.target);
|
|
1239
|
+
gate(state, input.target, requirement);
|
|
1257
1240
|
const entry = {
|
|
1258
1241
|
stage: input.target,
|
|
1259
1242
|
at: input.now,
|
|
1260
1243
|
by: input.member,
|
|
1244
|
+
requirementId: requirement.id,
|
|
1261
1245
|
...(note === undefined ? {} : { note }),
|
|
1262
1246
|
tracking: input.tracking,
|
|
1263
1247
|
};
|
|
1264
1248
|
state.workflow.push(entry);
|
|
1265
|
-
|
|
1249
|
+
requirement.stage = input.target;
|
|
1250
|
+
if (input.target === 'delivery') enqueueMerge(state, requirement, input.now);
|
|
1251
|
+
if (input.target === 'retro') {
|
|
1252
|
+
// Retro is the last stage, so reaching it finishes the lane: the branch is
|
|
1253
|
+
// no longer waiting for `master`, and the focus moves to whatever is still
|
|
1254
|
+
// running. A delivered lane keeps its history but stops being work.
|
|
1255
|
+
requirement.status = 'delivered';
|
|
1256
|
+
requirement.deliveredAt = input.now;
|
|
1257
|
+
dropFromMergeQueue(state, requirement.id);
|
|
1258
|
+
if (state.activeRequirementId === requirement.id) focusOldestRunning(state);
|
|
1259
|
+
} else if (state.activeRequirementId === requirement.id) {
|
|
1260
|
+
state.stage = input.target;
|
|
1261
|
+
}
|
|
1266
1262
|
state.updatedAt = input.now;
|
|
1267
1263
|
return entry;
|
|
1268
1264
|
}
|
|
1269
1265
|
|
|
1270
1266
|
/**
|
|
1271
|
-
*
|
|
1267
|
+
* The lane a decision belongs to.
|
|
1268
|
+
*
|
|
1269
|
+
* @param {object} state - ledger state.
|
|
1270
|
+
* @param {object} decision - decision record.
|
|
1271
|
+
* @returns {object | undefined} lane record.
|
|
1272
|
+
*/
|
|
1273
|
+
function decisionLane(state, decision) {
|
|
1274
|
+
return (state.requirements ?? []).find((candidate) => candidate.id === decision.requirementId);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Enforce one stage gate against one lane's requirement and decision.
|
|
1272
1279
|
*
|
|
1273
1280
|
* plan needs a tracked requirement, vote needs a decision for it, implementation
|
|
1274
1281
|
* needs that decision approved, retro needs minutes. `vote` used to require an
|
|
@@ -1281,30 +1288,24 @@ export function advanceWorkflow(state, input) {
|
|
|
1281
1288
|
*
|
|
1282
1289
|
* @param {object} state - ledger state.
|
|
1283
1290
|
* @param {string} target - target stage.
|
|
1291
|
+
* @param {object} requirement - lane the stage belongs to.
|
|
1284
1292
|
* @returns {void}
|
|
1285
1293
|
*/
|
|
1286
|
-
function gate(state, target) {
|
|
1287
|
-
assertStageProof(state, target);
|
|
1288
|
-
if (
|
|
1289
|
-
target === 'plan' &&
|
|
1290
|
-
(state.activeRequirementId === null ||
|
|
1291
|
-
!state.requirements.some((requirement) => requirement.id === state.activeRequirementId))
|
|
1292
|
-
) {
|
|
1293
|
-
throw new TeamError('stage "plan" requires an active tracked requirement', 'gate-unsatisfied');
|
|
1294
|
-
}
|
|
1294
|
+
function gate(state, target, requirement) {
|
|
1295
|
+
assertStageProof(state, target, requirement);
|
|
1295
1296
|
if (target === 'vote') {
|
|
1296
|
-
const decision =
|
|
1297
|
-
if (decision === undefined) throw new TeamError(
|
|
1297
|
+
const decision = decisionOf(state, requirement);
|
|
1298
|
+
if (decision === undefined) throw new TeamError(`stage "vote" requires a decision for ${requirement.id}; the Lead proposes one, then all four voting seats vote on it`, 'gate-unsatisfied');
|
|
1298
1299
|
// Approved: nothing is left to vote, so the stage that records the settled
|
|
1299
1300
|
// direction opens. Rejected with every seat heard: the vote is over and
|
|
1300
1301
|
// re-entering it would only relitigate the same proposal.
|
|
1301
|
-
if (decision.status === 'rejected' && tally(decision).missing === 0) throw new TeamError(`stage "vote" cannot settle ${decision.id}: all four seats have voted to reject it; the Lead proposes a different decision (there is no cap) or the
|
|
1302
|
+
if (decision.status === 'rejected' && tally(decision).missing === 0) throw new TeamError(`stage "vote" cannot settle ${decision.id}: all four seats have voted to reject it; the Lead proposes a different decision (there is no cap) or the lane closes with abandon`, 'gate-unsatisfied');
|
|
1302
1303
|
}
|
|
1303
1304
|
if (target === 'implementation') {
|
|
1304
|
-
const decision =
|
|
1305
|
+
const decision = decisionOf(state, requirement);
|
|
1305
1306
|
if (decision === undefined || decision.status !== 'approved') {
|
|
1306
1307
|
throw new TeamError(
|
|
1307
|
-
|
|
1308
|
+
`stage "implementation" requires ${requirement.decisionId ?? 'the lane\'s decision'} to be approved by all four seats`,
|
|
1308
1309
|
'gate-unsatisfied',
|
|
1309
1310
|
);
|
|
1310
1311
|
}
|
|
@@ -1314,16 +1315,28 @@ function gate(state, target) {
|
|
|
1314
1315
|
!state.meetings.some(
|
|
1315
1316
|
(meeting) =>
|
|
1316
1317
|
meeting.kind === 'retro' &&
|
|
1317
|
-
(meeting.requirementId ?? null) ===
|
|
1318
|
+
(meeting.requirementId ?? null) === requirement.id,
|
|
1318
1319
|
)
|
|
1319
1320
|
) {
|
|
1320
1321
|
throw new TeamError(
|
|
1321
|
-
|
|
1322
|
+
`stage "retro" requires a tracked retro meeting for ${requirement.id}`,
|
|
1322
1323
|
'gate-unsatisfied',
|
|
1323
1324
|
);
|
|
1324
1325
|
}
|
|
1325
1326
|
}
|
|
1326
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* The decision one lane is working on.
|
|
1330
|
+
*
|
|
1331
|
+
* @param {object} state - ledger state.
|
|
1332
|
+
* @param {object} requirement - lane record.
|
|
1333
|
+
* @returns {object | undefined} decision record.
|
|
1334
|
+
*/
|
|
1335
|
+
export function decisionOf(state, requirement) {
|
|
1336
|
+
if (requirement?.decisionId === null || requirement?.decisionId === undefined) return undefined;
|
|
1337
|
+
return (state.decisions ?? []).find((candidate) => candidate.id === requirement.decisionId);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1327
1340
|
/**
|
|
1328
1341
|
* One seat's public view, including its human-owned model route.
|
|
1329
1342
|
*
|
|
@@ -1364,25 +1377,6 @@ function roleSnapshot(state, role, roleModels) {
|
|
|
1364
1377
|
* @param {{ tracking?: object, runtime?: object }} [extra] - connection and runtime facts.
|
|
1365
1378
|
* @returns {object} public snapshot.
|
|
1366
1379
|
*/
|
|
1367
|
-
/**
|
|
1368
|
-
* Work that arrived while a cycle was running.
|
|
1369
|
-
*
|
|
1370
|
-
* Recorded, never refused, and it takes the stage when the cycle ends — the
|
|
1371
|
-
* status view lists it so a queued ask is visible instead of looking lost.
|
|
1372
|
-
*
|
|
1373
|
-
* @param {object} state - ledger state.
|
|
1374
|
-
* @returns {{ id: string, title: string, queuedAt: string | null }[]} queued requirements.
|
|
1375
|
-
*/
|
|
1376
|
-
function queuedRequirements(state) {
|
|
1377
|
-
return (state.requirements ?? [])
|
|
1378
|
-
.filter((candidate) => candidate.status === 'queued')
|
|
1379
|
-
.map((candidate) => ({
|
|
1380
|
-
id: candidate.id,
|
|
1381
|
-
title: candidate.title,
|
|
1382
|
-
queuedAt: candidate.queuedAt ?? null,
|
|
1383
|
-
}));
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
1380
|
export function publicSnapshot(state, extra = {}) {
|
|
1387
1381
|
const roleModels = state.roleModels ?? {};
|
|
1388
1382
|
const roles = catalogRoles(state).map((role) => roleSnapshot(state, role, roleModels));
|
|
@@ -1401,7 +1395,8 @@ export function publicSnapshot(state, extra = {}) {
|
|
|
1401
1395
|
stageName: stageName(state.stage),
|
|
1402
1396
|
activeRequirementId: state.activeRequirementId ?? null,
|
|
1403
1397
|
activeDecisionId: state.activeDecisionId ?? null,
|
|
1404
|
-
|
|
1398
|
+
lanes: laneSummaries(state),
|
|
1399
|
+
mergeQueue: mergeQueueRows(state),
|
|
1405
1400
|
problem: problemStatus(state),
|
|
1406
1401
|
evidence: (state.evidence ?? []).slice(-50),
|
|
1407
1402
|
createdAt: state.createdAt,
|
|
@@ -1593,10 +1588,13 @@ export function statusReport(state, options = {}) {
|
|
|
1593
1588
|
activeRequirementId: view.activeRequirementId,
|
|
1594
1589
|
activeDecisionId: view.activeDecisionId,
|
|
1595
1590
|
activeDecision: current === undefined ? null : boundedDecision(current),
|
|
1591
|
+
lanes: view.lanes,
|
|
1592
|
+
mergeQueue: view.mergeQueue,
|
|
1596
1593
|
problem: boundedProblem(view.problem),
|
|
1597
1594
|
counts: {
|
|
1598
1595
|
identities: state.identities.length,
|
|
1599
1596
|
requirements: state.requirements.length,
|
|
1597
|
+
lanes: view.lanes.length,
|
|
1600
1598
|
decisions: state.decisions.length,
|
|
1601
1599
|
meetings: state.meetings.length,
|
|
1602
1600
|
reviews: (state.reviews ?? []).length,
|
|
@@ -1743,4 +1741,6 @@ function normalizeTeamDocument(value) {
|
|
|
1743
1741
|
if (value.activeRequirementId === undefined) value.activeRequirementId = null;
|
|
1744
1742
|
if (value.activeDecisionId === undefined) value.activeDecisionId = null;
|
|
1745
1743
|
if (value.watchdog === undefined) value.watchdog = null;
|
|
1744
|
+
backfillLanes(value);
|
|
1746
1745
|
}
|
|
1746
|
+
|
package/lib/team-model.js
CHANGED
|
@@ -407,9 +407,9 @@ export const PRINCIPLES = Object.freeze([
|
|
|
407
407
|
text: 'Missing hardware, a kernel, a dependency or a remote peer is not a blocker by itself. Cross-compile or JIT for an absent target device, build maintenance-support dependencies from source, and measure a transport on loopback before waiting for a peer. The Host accepts a blocked report once it observes an attempt; before that, the only honest move is to run the attempt. Workaround runs are detected and surfaced by the Host, never passed off as a fix.',
|
|
408
408
|
}),
|
|
409
409
|
Object.freeze({
|
|
410
|
-
id: '
|
|
411
|
-
title: '
|
|
412
|
-
text: '
|
|
410
|
+
id: 'parallel-lanes',
|
|
411
|
+
title: 'Many lanes at once, one master',
|
|
412
|
+
text: 'Several requirements run side by side: each lane carries its own stage, its own decision and its own evidence, so a second ask starts work while the first is still being implemented. Intake still takes the whole ask, every criterion listed at once, because splitting one ask into several lanes pays the vote and the acceptance round once per slice and hides the real work. What stays serial is the merge: deliveries join a queue and land on master one at a time, so master builds at every step. Proof is per lane: a commit names its branch, a command names its worktree, and another lane’s green run never satisfies this one.',
|
|
413
413
|
}),
|
|
414
414
|
Object.freeze({
|
|
415
415
|
id: 'fast-fail',
|
|
@@ -523,7 +523,7 @@ You are the Assistant (assistant) of the Infinity Fabric AI Team. You are the se
|
|
|
523
523
|
2. Intake and handoff
|
|
524
524
|
- Extract the user's goal, acceptance criteria, boundaries, non-goals and risks.
|
|
525
525
|
- Call infinity_team action "intake". It creates the tracked GitFox requirement. If no project is pinned yet, ask the Lead (or call action "configure" as lead) to pin one first; without tracking, no planning or implementation may exist.
|
|
526
|
-
- One
|
|
526
|
+
- One lane per requirement, several lanes at once. Intake carries the whole ask: every criterion the user named, listed at once. A second intake opens its own lane at intake and starts work immediately instead of waiting behind the first; nothing the user asked for is lost and no case-by-case loop opens. Pass "requirement_id" whenever an action should land on a lane other than the focused one. Give each lane its own tree: the worker calls create_worktree for that lane (name it after the requirement id) and reports the branch and path back; you bind it with action "lane", and only you and the assistant can bind, because the binding decides whose evidence a commit is. From then on the worker runs every command inside that directory, so the Host attributes commits and runs to the right lane. If create_worktree is not available in this profile, run one lane at a time in the shared checkout: two workers in one tree cannot be told apart, and the evidence would land on the wrong lane. Delivery queues the lane for merge; master takes one branch at a time. If a check is wrong, use "amend"; if the ask grows, it belongs in the open requirement.
|
|
527
527
|
- Then send the requirement id to lead with team_send_message. The Lead owns planning and execution.
|
|
528
528
|
|
|
529
529
|
3. Identities and HR
|
package/lib/tool-schema.js
CHANGED
|
@@ -17,6 +17,7 @@ const ACTIONS = [
|
|
|
17
17
|
'configure',
|
|
18
18
|
'intake',
|
|
19
19
|
'abandon',
|
|
20
|
+
'lane',
|
|
20
21
|
'amend',
|
|
21
22
|
'meeting',
|
|
22
23
|
'propose_decision',
|
|
@@ -36,7 +37,7 @@ const ACTIONS = [
|
|
|
36
37
|
|
|
37
38
|
/** Model-facing description of the `infinity_team` tool. */
|
|
38
39
|
export const TOOL_DESCRIPTION =
|
|
39
|
-
'Infinity Fabric AI Team governance. "status" is a bounded read-only view: it leads with the active decision and the problem block and appends only the tables named in "include"; "roster" is read-only; "configure" pins the GitFox project locally (no issue; normally auto-discovered from the workspace remote); "intake" opens a tracked requirement with acceptance criteria plus one check per criterion (a criterion without a check is an opinion; the gate reads whether the check actually ran, not whether its wording looks like a command), and it
|
|
40
|
+
'Infinity Fabric AI Team governance. "status" is a bounded read-only view: it leads with the active decision and the problem block, always lists the open lanes and the merge queue, and appends only the history tables named in "include"; "roster" is read-only; "configure" pins the GitFox project locally (no issue; normally auto-discovered from the workspace remote); "intake" opens a tracked requirement with acceptance criteria plus one check per criterion (a criterion without a check is an opinion; the gate reads whether the check actually ran, not whether its wording looks like a command), and it opens a lane: every requirement carries its own stage, its own decision and its own evidence, so several asks run side by side instead of waiting in a queue and nothing the user asked for is lost; "abandon" closes one lane without completion (pass "requirement_id", or the focused lane closes; reason required: the requirement is marked abandoned, its issue is annotated, nothing is reported as done, and the other lanes keep running); "lane" reports one lane and, when "branch" and "worktree" are given (assistant or Lead only), records where its work lives so its commits and commands are attributed to it and never to another lane; "amend" swaps the command behind one criterion in place, keeping the criterion, the previous command and every run already recorded, so a check written before the flag existed does not cost a whole cycle; reopening the same request after an abandon needs "reopenReason", and a fourth attempt at the same request is refused; "meeting" records tracked minutes; "propose_decision"/"vote"/"decide" enforce the fixed four-seat unanimity, accept as many decisions for one requirement as it needs (there is no cap: every proposal still costs a fresh vote by all four seats, which is what bounds the loop), refuse "vote" and "decide" outside the vote stage so a decision takes effect where its votes were cast, and keep a rejected decision open until every seat has recorded its vote: a reject decides the outcome at once but never silences the remaining seats; "workflow" advances one lane exactly one stage — pass "requirement_id" to move a lane that is not in focus — and its gate is read from the work the Host observed in that lane (a real commit on that lane’s branch, that lane’s own check runs), never from a ticket or a label; a workspace that stays local is exempt from the commit bars: no stage waits for a commit and a check need not follow one, while the runs remain the evidence; "reviews" returns one newest-first page of the shared-state review queue (per_page 1-100, default 20; pass the returned "next_cursor" as "cursor" to drain it) and "review" records a required seat verdict; "publish" composes the cycle work record from the runs and commits the Host observed and posts it as a comment on the requirement ticket (the model does not write the record). Closing a ticket or merging a merge request only needs that lane’s checks to have been attempted once (a red or old run is reported to the seats, not held against the action), and a lane ticket also needs its record published plus a docs+qa review before it closes. A process markdown file committed to the repository (evidence, gap reports, notes, retros) is refused: work is recorded on the ticket, the repository records the product. HR-only "define_role" creates a position the charter never had (id, name, mandate and the exact tool surface as a "capabilities" pair), "retire_role" closes one permanently, and "enroll"/"retire"/"spawn_member" manage the local roster. A defined position never holds a voting seat, and assistant/lead/hr can never be defined or retired. Model routing belongs exclusively to the human operator: the AI Team panel sets one team default and an optional pin per seat, and no Agent action can choose or change a model — HR included. Teams are per workspace; setup, charter and identities are local. A workspace that stays local still runs the team but opens GitFox issues/MR only when the user asks.';
|
|
40
41
|
|
|
41
42
|
/** JSON-schema parameters of the `infinity_team` tool. */
|
|
42
43
|
export const TOOL_PARAMETERS = {
|
|
@@ -45,7 +46,11 @@ export const TOOL_PARAMETERS = {
|
|
|
45
46
|
properties: {
|
|
46
47
|
action: { type: 'string', enum: ACTIONS, description: 'Team operation.' },
|
|
47
48
|
project: { type: 'string', description: 'GitFox project in "namespace/name" form for "configure"; omit to keep the auto-discovered binding.' },
|
|
48
|
-
requirement_id: {
|
|
49
|
+
requirement_id: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description:
|
|
52
|
+
'Tracked requirement id. Names the lane for "lane", "workflow" and "abandon"; for "propose_decision" it names the requirement the decision is for.',
|
|
53
|
+
},
|
|
49
54
|
decision_id: { type: 'string', description: 'Decision id for "vote"/"decide".' },
|
|
50
55
|
title: { type: 'string', description: 'Requirement or decision title.' },
|
|
51
56
|
request: { type: 'string', description: 'Verbatim user request for "intake".' },
|
|
@@ -90,6 +95,17 @@ export const TOOL_PARAMETERS = {
|
|
|
90
95
|
description: 'Immediate next workflow stage.',
|
|
91
96
|
},
|
|
92
97
|
note: { type: 'string', description: 'Transition note.' },
|
|
98
|
+
branch: { type: 'string', description: 'For "lane": the branch this lane commits to.' },
|
|
99
|
+
mr: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description:
|
|
102
|
+
'For "lane": the merge request that will land this lane, as "project#iid". The merge gate then checks this lane, not whichever lane is focused.',
|
|
103
|
+
},
|
|
104
|
+
worktree: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description:
|
|
107
|
+
'For "lane": the absolute directory this lane works in (the path "create_worktree" returned). Commands run there are attributed to this lane.',
|
|
108
|
+
},
|
|
93
109
|
review_id: { type: 'string', description: 'Review id for "review" (RV-0001 style).' },
|
|
94
110
|
verdict: { type: 'string', enum: REVIEW_VERDICTS, description: 'Required-seat verdict for "review".' },
|
|
95
111
|
op: {
|