gcf-aif-ext 0.7.2 → 0.7.4
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/extension.json +1 -1
- package/lib/bundle.js +9 -1
- package/lib/integrate.js +2 -0
- package/lib/ledger.js +4 -2
- package/lib/work-ops.js +88 -29
- package/package.json +1 -1
package/extension.json
CHANGED
package/lib/bundle.js
CHANGED
|
@@ -180,13 +180,21 @@ function plan(bundle, cur, ledger) {
|
|
|
180
180
|
const writes = [];
|
|
181
181
|
const note = `lines outside any part: ${bundle.stray.join(', ')} (a lost gcf:part marker?)`;
|
|
182
182
|
const report = bundle.stray.length ? [{ part: 'bundle', outcome: 'invalid', note }] : [];
|
|
183
|
+
const keyOf = (p) => `${p.kind}:${p.name}`;
|
|
184
|
+
const keys = bundle.parts.map(keyOf);
|
|
183
185
|
for (const p of bundle.parts) {
|
|
184
|
-
const key =
|
|
186
|
+
const key = keyOf(p);
|
|
185
187
|
if (!validName(p)) {
|
|
186
188
|
report.push({ part: key, outcome: 'invalid', note: 'not a gcf doc, system or unit name' });
|
|
187
189
|
continue;
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
// Which copy is the designer's edit is not ours to guess: the later one would win silently.
|
|
193
|
+
if (keys.indexOf(key) !== keys.lastIndexOf(key)) {
|
|
194
|
+
report.push({ part: key, outcome: 'invalid', note: 'the bundle holds this part twice' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
190
198
|
const v = verdict(p, cur.get(key) ?? null);
|
|
191
199
|
if (p.kind === 'unit' && v === 'new' && ledger.known(p.name)) {
|
|
192
200
|
report.push({ part: key, outcome: 'taken', note: 'the id is used: pick another slug' });
|
package/lib/integrate.js
CHANGED
|
@@ -302,6 +302,8 @@ export async function integrate(projectDir, ids = []) {
|
|
|
302
302
|
throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
|
|
303
303
|
|
|
304
304
|
return withMainLock(projectDir, async () => {
|
|
305
|
+
// Nothing to merge leaves main alone: a run's first integrate must not refuse a dirty tree.
|
|
306
|
+
if (!ids.length && !verifiedBranches(projectDir).length) return [];
|
|
305
307
|
if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
|
|
306
308
|
throw new Error('main has uncommitted changes: commit or stash them before integrating');
|
|
307
309
|
commitMetrics(projectDir);
|
package/lib/ledger.js
CHANGED
|
@@ -71,9 +71,11 @@ export async function buildLedger(projectDir) {
|
|
|
71
71
|
for (const d of design) for (const u of d.units) add(u.id, { state: 'design', file: d.file });
|
|
72
72
|
for (const c of items) add(String(c.data.id), { state: 'item', file: `work/${c.name}` });
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// A list, not a set: two equal lines are two ends too (M12), a merge of branches that each
|
|
75
|
+
// ended the id the same way.
|
|
76
|
+
/** @type {Map<string, string[]>} */
|
|
75
77
|
const terminal = new Map();
|
|
76
|
-
const mark = (id, event) => terminal.set(id, (terminal.get(id) ??
|
|
78
|
+
const mark = (id, event) => terminal.set(id, [...(terminal.get(id) ?? []), event]);
|
|
77
79
|
for (const [id, event] of await recordLines(root, 'history.md'))
|
|
78
80
|
if (HISTORY_EVENTS.includes(event)) mark(id, event);
|
|
79
81
|
for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
|
package/lib/work-ops.js
CHANGED
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { parse, serialize } from './frontmatter.js';
|
|
12
12
|
import { buildLedger } from './ledger.js';
|
|
13
13
|
import { readConfig } from './gcf-paths.js';
|
|
14
|
-
import { atomicWrite } from './safe-path.js';
|
|
14
|
+
import { atomicWrite, resolveInside } from './safe-path.js';
|
|
15
15
|
import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
|
|
16
16
|
import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
|
|
17
17
|
import { isValidId } from './id.js';
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from './live.js';
|
|
38
38
|
import { dropInbox } from './inbox.js';
|
|
39
39
|
import { refuseByPhase } from './phase.js';
|
|
40
|
-
import { runHolder } from './main-lock.js';
|
|
40
|
+
import { runHolder, withMainLock } from './main-lock.js';
|
|
41
41
|
import { releaseSession, sessionActive } from './session-mark.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -160,14 +160,13 @@ function unfinishedChildren(ledger, feature) {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
163
|
+
* Refuse a move on item `id` while a harness run or another agent session works it. `self` is
|
|
164
|
+
* the item this session drives itself (null: none).
|
|
165
|
+
* @param {string} projectDir
|
|
166
|
+
* @param {string} id
|
|
167
|
+
* @param {string | null} [self]
|
|
165
168
|
*/
|
|
166
|
-
|
|
167
|
-
const root = gcfOf(projectDir);
|
|
168
|
-
const ledger = await buildLedger(projectDir);
|
|
169
|
-
const item = itemOf(ledger, id);
|
|
170
|
-
need(move, item);
|
|
169
|
+
function refuseRunning(projectDir, id, self = process.env.GCF_RUN_ITEM ?? null) {
|
|
171
170
|
const run = runHolder(projectDir, id);
|
|
172
171
|
if (run)
|
|
173
172
|
throw new Error(
|
|
@@ -176,11 +175,23 @@ async function begin(projectDir, id, move) {
|
|
|
176
175
|
);
|
|
177
176
|
// The session that claimed the item moves it itself (GCF_RUN_ITEM, set by `--claimed` or by
|
|
178
177
|
// a harness run on main); anyone else waits, as for a run lock.
|
|
179
|
-
if (sessionActive(projectDir, id) &&
|
|
178
|
+
if (sessionActive(projectDir, id) && self !== id)
|
|
180
179
|
throw new Error(
|
|
181
180
|
`${id} is worked by an agent session (gcf claim): wait until it moves the item, or run ` +
|
|
182
181
|
`ai-factory gcf release ${id} when no session works it`,
|
|
183
182
|
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Open a move on item `id` from the statuses `move` allows: the files it touches, its steps,
|
|
187
|
+
* and the LIVE.md edits folded into a single write at the end.
|
|
188
|
+
*/
|
|
189
|
+
async function begin(projectDir, id, move) {
|
|
190
|
+
const root = gcfOf(projectDir);
|
|
191
|
+
const ledger = await buildLedger(projectDir);
|
|
192
|
+
const item = itemOf(ledger, id);
|
|
193
|
+
need(move, item);
|
|
194
|
+
refuseRunning(projectDir, id);
|
|
184
195
|
const files = [item.file];
|
|
185
196
|
const steps = [];
|
|
186
197
|
const liveOps = [];
|
|
@@ -229,6 +240,16 @@ const writeItem = (item, data, body = item.body) => atomicWrite(item.file, seria
|
|
|
229
240
|
const without = (data, ...keys) =>
|
|
230
241
|
Object.fromEntries(Object.entries(data).filter(([k]) => !keys.includes(k)));
|
|
231
242
|
|
|
243
|
+
/** Refuse a move on an unplayed item edited since its close; `then` is where the edit goes. */
|
|
244
|
+
function refuseEdited(item, then) {
|
|
245
|
+
if (matchesFrozen(item)) return;
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
|
|
248
|
+
`save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
|
|
249
|
+
`discards it), make the move, then ${then}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
232
253
|
/**
|
|
233
254
|
* The item's lines leave its systems docs and its added capability leaves LIVE.md. A line
|
|
234
255
|
* another unplayed item also wrote stays: that item's body still owes it to systems/.
|
|
@@ -236,12 +257,7 @@ const without = (data, ...keys) =>
|
|
|
236
257
|
function unplay(m, item) {
|
|
237
258
|
// The gate checks `frozen` only on a file that stays unplayed: a tune or a kill of an edited
|
|
238
259
|
// item would take the edited lines out and leave the frozen ones in systems/ for good.
|
|
239
|
-
|
|
240
|
-
throw new Error(
|
|
241
|
-
`${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
|
|
242
|
-
`save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
|
|
243
|
-
'discards it), make the move, then apply the edit to the ready item',
|
|
244
|
-
);
|
|
260
|
+
refuseEdited(item, 'apply the edit to the ready item');
|
|
245
261
|
const systems = item.data.system ?? [];
|
|
246
262
|
m.leaving.add(String(item.data.id));
|
|
247
263
|
m.touch(...systemFiles(m.root, systems));
|
|
@@ -283,7 +299,8 @@ function tracked(projectDir, file) {
|
|
|
283
299
|
*/
|
|
284
300
|
function archivePlan(m, item) {
|
|
285
301
|
const rel = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
286
|
-
|
|
302
|
+
// The plan is moved and deleted: a `plan:` out of the project would take another tree's file.
|
|
303
|
+
const src = resolveInside(m.projectDir, rel);
|
|
287
304
|
if (!existsSync(src) || !tracked(m.projectDir, src)) return null;
|
|
288
305
|
const destRel = `.ai-factory/archive/plans/${path.basename(src)}`;
|
|
289
306
|
const dest = path.resolve(m.projectDir, destRel);
|
|
@@ -456,7 +473,7 @@ function refuseOpenDepends(m, move, opts = {}) {
|
|
|
456
473
|
/** ready → awaiting-verdict / awaiting-decision; `since` marks where the wait began.
|
|
457
474
|
* @param {MoveOpts} [opts]
|
|
458
475
|
*/
|
|
459
|
-
|
|
476
|
+
async function submitItemUnlocked(projectDir, id, opts = {}) {
|
|
460
477
|
const { commit = true } = opts;
|
|
461
478
|
const m = await begin(projectDir, id, 'submit');
|
|
462
479
|
if (m.item.data.gate === 'auto')
|
|
@@ -528,7 +545,7 @@ function closeFeature(m) {
|
|
|
528
545
|
* unit collapses into its systems docs and stays as unplayed; anything else ends in history.
|
|
529
546
|
* @param {MoveOpts} [opts]
|
|
530
547
|
*/
|
|
531
|
-
|
|
548
|
+
async function closeItemUnlocked(projectDir, id, opts = {}) {
|
|
532
549
|
const { constraints = [], commit = true } = opts;
|
|
533
550
|
const m = await begin(projectDir, id, 'close');
|
|
534
551
|
const unplayed = SOFT.includes(m.item.data.type);
|
|
@@ -554,6 +571,8 @@ async function keep(m, constraints) {
|
|
|
554
571
|
const { item } = m;
|
|
555
572
|
refuseOpenDepends(m, 'verdict keep');
|
|
556
573
|
if (item.data.status === 'unplayed') {
|
|
574
|
+
// systems/ and LIVE.md hold the frozen body: an edit made since would leave with the file.
|
|
575
|
+
refuseEdited(item, `carry the edit in a new item with reopens: ${item.data.id}`);
|
|
557
576
|
endToHistory(m, item, 'collapsed', { applyLive: false });
|
|
558
577
|
|
|
559
578
|
return;
|
|
@@ -599,7 +618,7 @@ async function tune(m, note) {
|
|
|
599
618
|
/** keep | tune | kill on an item waiting for its verdict or unplayed (Methodology §5.4).
|
|
600
619
|
* @param {MoveOpts} [opts]
|
|
601
620
|
*/
|
|
602
|
-
|
|
621
|
+
async function verdictItemUnlocked(projectDir, id, verdict, opts = {}) {
|
|
603
622
|
const { note = '', tags = [], remove, constraints = [], commit = true } = opts;
|
|
604
623
|
if (!['keep', 'tune', 'kill'].includes(verdict)) throw new Error(`Unknown verdict: ${verdict}`);
|
|
605
624
|
const m = await begin(projectDir, id, `verdict:${verdict}`);
|
|
@@ -617,9 +636,11 @@ export async function verdictItem(projectDir, id, verdict, opts = {}) {
|
|
|
617
636
|
function killFeature(m, { reason, remove }) {
|
|
618
637
|
const { item, ledger } = m;
|
|
619
638
|
const shipped = featureShipped(ledger, item);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
639
|
+
const children = (item.data.children ?? []).map((c) => ledger.item(c)).filter(Boolean);
|
|
640
|
+
// A child's file goes with the feature: an agent working it would lose it mid-run.
|
|
641
|
+
// No session kills its own item through its feature: the claimed child counts as running too.
|
|
642
|
+
for (const child of children) refuseRunning(m.projectDir, String(child.data.id), null);
|
|
643
|
+
for (const child of children) {
|
|
623
644
|
endToGraveyard(m, child, {
|
|
624
645
|
reason: `feature ${item.data.id} killed: ${reason}`,
|
|
625
646
|
removal: false,
|
|
@@ -674,7 +695,7 @@ function fitDecision(choice, item) {
|
|
|
674
695
|
/** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
|
|
675
696
|
* @param {MoveOpts} [opts]
|
|
676
697
|
*/
|
|
677
|
-
|
|
698
|
+
async function decideItemUnlocked(projectDir, id, choice, opts = {}) {
|
|
678
699
|
const { reason = '', remove, commit = true } = opts;
|
|
679
700
|
if (!DECIDE[choice])
|
|
680
701
|
throw new Error(`Unknown decision: ${choice} (${Object.keys(DECIDE).join(' | ')})`);
|
|
@@ -689,7 +710,7 @@ export async function decideItem(projectDir, id, choice, opts = {}) {
|
|
|
689
710
|
/** ready → blocked, with the reason or the question for the operator.
|
|
690
711
|
* @param {MoveOpts} [opts]
|
|
691
712
|
*/
|
|
692
|
-
|
|
713
|
+
async function blockItemUnlocked(projectDir, id, opts = {}) {
|
|
693
714
|
const { reason, commit = true } = opts;
|
|
694
715
|
const why = String(reason ?? '').trim();
|
|
695
716
|
if (!why) throw new Error('a block needs a reason or a question');
|
|
@@ -703,7 +724,7 @@ export async function blockItem(projectDir, id, opts = {}) {
|
|
|
703
724
|
/** blocked | parked → ready; a note (an answer, a delivered asset file) goes to `notes:`.
|
|
704
725
|
* @param {MoveOpts} [opts]
|
|
705
726
|
*/
|
|
706
|
-
|
|
727
|
+
async function unblockItemUnlocked(projectDir, id, opts = {}) {
|
|
707
728
|
const { note = '', commit = true } = opts;
|
|
708
729
|
const m = await begin(projectDir, id, 'unblock');
|
|
709
730
|
if (m.item.data.status === 'parked') dropKnownIssue(m, id);
|
|
@@ -725,7 +746,7 @@ export async function unblockItem(projectDir, id, opts = {}) {
|
|
|
725
746
|
/** Any stored status → graveyard; an unplayed item leaves as a kill does.
|
|
726
747
|
* @param {MoveOpts} [opts]
|
|
727
748
|
*/
|
|
728
|
-
|
|
749
|
+
async function dropItemUnlocked(projectDir, id, opts = {}) {
|
|
729
750
|
const { reason, tags = [], remove, commit = true } = opts;
|
|
730
751
|
const m = await begin(projectDir, id, 'drop');
|
|
731
752
|
if (m.item.data.type === 'feature' && (m.item.data.children ?? []).some((c) => m.ledger.item(c)))
|
|
@@ -855,7 +876,7 @@ function newItemText(spec, gate, since) {
|
|
|
855
876
|
* Rank items in the given order: `priority` 1, 2, 3… (lower runs sooner). One commit
|
|
856
877
|
* `gcf(priority)`. A board sends a column's new order after a drag; the console names the ids.
|
|
857
878
|
*/
|
|
858
|
-
|
|
879
|
+
async function prioritizeItemsUnlocked(projectDir, ids, { commit = true } = {}) {
|
|
859
880
|
if (!ids.length) throw new Error('name the items in the order they should run');
|
|
860
881
|
if (new Set(ids).size !== ids.length) throw new Error('an id is named twice');
|
|
861
882
|
const ledger = await buildLedger(projectDir);
|
|
@@ -896,7 +917,7 @@ export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
|
|
|
896
917
|
* A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
|
|
897
918
|
* not here: its body is the unit's slots. `from` erases the inbox entry it came from.
|
|
898
919
|
*/
|
|
899
|
-
|
|
920
|
+
async function newItemUnlocked(projectDir, spec) {
|
|
900
921
|
const root = gcfOf(projectDir);
|
|
901
922
|
const ledger = await buildLedger(projectDir);
|
|
902
923
|
const gate = checkNew(ledger, spec);
|
|
@@ -930,3 +951,41 @@ export async function newItem(projectDir, spec) {
|
|
|
930
951
|
|
|
931
952
|
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
932
953
|
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* A committed move holds the main lock from its first read to its commit (Methodology §7): one
|
|
957
|
+
* that read the ledger before taking it would write over a move committed meanwhile.
|
|
958
|
+
* @template T
|
|
959
|
+
* @param {string} projectDir
|
|
960
|
+
* @param {boolean} commit
|
|
961
|
+
* @param {() => Promise<T>} move
|
|
962
|
+
*/
|
|
963
|
+
const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
|
|
964
|
+
|
|
965
|
+
/** @type {typeof submitItemUnlocked} */
|
|
966
|
+
export const submitItem = (projectDir, id, opts = {}) =>
|
|
967
|
+
locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
|
|
968
|
+
/** @type {typeof closeItemUnlocked} */
|
|
969
|
+
export const closeItem = (projectDir, id, opts = {}) =>
|
|
970
|
+
locked(projectDir, opts.commit ?? true, () => closeItemUnlocked(projectDir, id, opts));
|
|
971
|
+
/** @type {typeof verdictItemUnlocked} */
|
|
972
|
+
export const verdictItem = (projectDir, id, verdict, opts = {}) =>
|
|
973
|
+
locked(projectDir, opts.commit ?? true, () => verdictItemUnlocked(projectDir, id, verdict, opts));
|
|
974
|
+
/** @type {typeof decideItemUnlocked} */
|
|
975
|
+
export const decideItem = (projectDir, id, choice, opts = {}) =>
|
|
976
|
+
locked(projectDir, opts.commit ?? true, () => decideItemUnlocked(projectDir, id, choice, opts));
|
|
977
|
+
/** @type {typeof blockItemUnlocked} */
|
|
978
|
+
export const blockItem = (projectDir, id, opts = {}) =>
|
|
979
|
+
locked(projectDir, opts.commit ?? true, () => blockItemUnlocked(projectDir, id, opts));
|
|
980
|
+
/** @type {typeof unblockItemUnlocked} */
|
|
981
|
+
export const unblockItem = (projectDir, id, opts = {}) =>
|
|
982
|
+
locked(projectDir, opts.commit ?? true, () => unblockItemUnlocked(projectDir, id, opts));
|
|
983
|
+
/** @type {typeof dropItemUnlocked} */
|
|
984
|
+
export const dropItem = (projectDir, id, opts = {}) =>
|
|
985
|
+
locked(projectDir, opts.commit ?? true, () => dropItemUnlocked(projectDir, id, opts));
|
|
986
|
+
/** @type {typeof prioritizeItemsUnlocked} */
|
|
987
|
+
export const prioritizeItems = (projectDir, ids, opts = {}) =>
|
|
988
|
+
locked(projectDir, opts.commit ?? true, () => prioritizeItemsUnlocked(projectDir, ids, opts));
|
|
989
|
+
/** @type {typeof newItemUnlocked} */
|
|
990
|
+
export const newItem = (projectDir, spec) =>
|
|
991
|
+
locked(projectDir, spec.commit ?? false, () => newItemUnlocked(projectDir, spec));
|