gcf-aif-ext 0.7.7 → 0.7.9
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 +13 -0
- package/commands/gcf.js +6 -1
- package/extension.json +1 -1
- package/lib/agent-permissions.js +11 -17
- package/lib/audit-core.js +4 -3
- package/lib/board.js +1 -1
- package/lib/config-set.js +25 -15
- package/lib/context.js +3 -2
- package/lib/design-check.js +3 -2
- package/lib/gcf-paths.js +2 -0
- package/lib/index-gen.js +4 -2
- package/lib/main-lock.js +11 -0
- package/lib/phase.js +6 -1
- package/lib/pillars.js +11 -2
- package/lib/question-fields.js +71 -0
- package/lib/questions.js +32 -15
- package/lib/unit-ops.js +14 -4
- package/lib/verify.js +2 -1
- package/lib/work-ops.js +1 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,19 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.7.9
|
|
54
|
+
|
|
55
|
+
- `gcf done`, `gcf drop` (a design unit), `gcf pillar rename|retire`, `gcf phase next` and `gcf question answer` hold the main lock from their first read to their commit, as the work item moves do. Before, two at once could both pass their checks and write over each other: an answer lost, a unit in the history twice.
|
|
56
|
+
- `gcf claim` says the mark lives 2 hours without a gcf command, as it does (it said 30 minutes).
|
|
57
|
+
|
|
58
|
+
### 0.7.8
|
|
59
|
+
|
|
60
|
+
- An open question's field written as a YAML block (`assumed: |` or `>`, and the lines under it) reads as its text: before, `gcf board --json` gave the block sign `|`. Every reader of `gcf/open-questions.md` (the board, `design check`, the audit, `context`, the index) reads the fields the same way:
|
|
61
|
+
- a field is a line at the record's field indent, so a block line such as `answer: x` is text: before, it made a D1 read as answered;
|
|
62
|
+
- a blank line inside a block no longer ends the record: before, the fields after it were lost;
|
|
63
|
+
- `gcf question answer` replaces an old answer written as a block whole: before, it replaced only the block's first line;
|
|
64
|
+
- an empty `answer:` is no answer: `design check` now holds the D1's units, as the board already showed it unanswered.
|
|
65
|
+
|
|
53
66
|
### 0.7.7
|
|
54
67
|
|
|
55
68
|
- `ai-factory gcf config [--json]` lists the human's settings of `gcf/config.yml` with what each means; `gcf config set <key> --value=<v>` sets one, checked by the rules a read applies, the comments kept, one `gcf(config)` commit. An empty number goes back to its default. `gcf board --json` carries them as `settings`.
|
package/commands/gcf.js
CHANGED
|
@@ -33,7 +33,7 @@ function registerSession(gcf) {
|
|
|
33
33
|
.command('claim <id>')
|
|
34
34
|
.description(
|
|
35
35
|
'An agent session starts working the item on main: the board shows it running until a ' +
|
|
36
|
-
'move ends it, release, or
|
|
36
|
+
'move ends it, release, or 2 hours without a gcf command on it',
|
|
37
37
|
)
|
|
38
38
|
.action(async (id) => {
|
|
39
39
|
const { buildLedger } = await lib('ledger');
|
|
@@ -977,6 +977,10 @@ function registerBoard(gcf) {
|
|
|
977
977
|
].join('\n'),
|
|
978
978
|
);
|
|
979
979
|
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The human's settings a board edits through the core: config keys and agent deny rules. */
|
|
983
|
+
function registerSettings(gcf) {
|
|
980
984
|
const config = gcf
|
|
981
985
|
.command('config')
|
|
982
986
|
.description("The human's settings of gcf/config.yml: list them, or set one (commit)")
|
|
@@ -1189,6 +1193,7 @@ export function register(program) {
|
|
|
1189
1193
|
registerVerify(gcf);
|
|
1190
1194
|
registerIntegrate(gcf);
|
|
1191
1195
|
registerBoard(gcf);
|
|
1196
|
+
registerSettings(gcf);
|
|
1192
1197
|
registerPhase(gcf);
|
|
1193
1198
|
registerHooksInstall(gcf);
|
|
1194
1199
|
registerLint(gcf);
|
package/extension.json
CHANGED
package/lib/agent-permissions.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import YAML from 'yaml';
|
|
11
10
|
import { commitMove } from './unit-ops.js';
|
|
12
11
|
import { atomicWrite } from './safe-path.js';
|
|
13
12
|
import { gcfRoot } from './gcf-paths.js';
|
|
13
|
+
import { configDoc, ensureMap } from './config-set.js';
|
|
14
|
+
|
|
15
|
+
const GROUPS_SHAPE = 'gcf/config.yml: agent.groups must map a group id to true or false';
|
|
14
16
|
|
|
15
17
|
// A shell command pattern turns into a rule for both shell tools: on Windows the agent runs
|
|
16
18
|
// commands through PowerShell, which Bash rules do not cover.
|
|
@@ -75,7 +77,8 @@ export const DENY_GROUPS = [
|
|
|
75
77
|
{
|
|
76
78
|
id: 'human-moves',
|
|
77
79
|
label:
|
|
78
|
-
"the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings,
|
|
80
|
+
"the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings, " +
|
|
81
|
+
'permissions, setup',
|
|
79
82
|
on: true,
|
|
80
83
|
rules: shell(
|
|
81
84
|
'ai-factory gcf verdict*',
|
|
@@ -159,12 +162,12 @@ function rules(agent, key) {
|
|
|
159
162
|
*/
|
|
160
163
|
function choices(agent) {
|
|
161
164
|
const g = agent.groups ?? {};
|
|
162
|
-
if (typeof g !== 'object' || Array.isArray(g))
|
|
163
|
-
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
165
|
+
if (typeof g !== 'object' || Array.isArray(g)) throw new Error(GROUPS_SHAPE);
|
|
164
166
|
for (const [id, on] of Object.entries(g))
|
|
165
167
|
if (!known(id) || typeof on !== 'boolean')
|
|
166
168
|
throw new Error(
|
|
167
|
-
`gcf/config.yml: agent.groups.${id} must be true or false,
|
|
169
|
+
`gcf/config.yml: agent.groups.${id} must be true or false, ` +
|
|
170
|
+
`for one of ${GROUP_IDS.join(' | ')}`,
|
|
168
171
|
);
|
|
169
172
|
|
|
170
173
|
return g;
|
|
@@ -214,18 +217,9 @@ export async function setPermissions(projectDir, change, { commit = true } = {})
|
|
|
214
217
|
const hash = await commitMove(projectDir, 'gcf(permissions): agent deny rules', {
|
|
215
218
|
files: [file],
|
|
216
219
|
mutate: async () => {
|
|
217
|
-
const doc =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// A blank `agent:` (or one holding only comments) is no map yet.
|
|
221
|
-
const agent = doc.get('agent', true);
|
|
222
|
-
if (agent != null && !YAML.isMap(agent) && !(YAML.isScalar(agent) && agent.value == null))
|
|
223
|
-
throw new Error('gcf/config.yml: agent must be a mapping');
|
|
224
|
-
if (!YAML.isMap(agent)) doc.set('agent', doc.createNode({}));
|
|
225
|
-
const groups = doc.getIn(['agent', 'groups'], true);
|
|
226
|
-
if (groups != null && !YAML.isMap(groups) && !(YAML.isScalar(groups) && groups.value == null))
|
|
227
|
-
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
228
|
-
if (!YAML.isMap(groups)) doc.setIn(['agent', 'groups'], doc.createNode({}));
|
|
220
|
+
const doc = configDoc(existsSync(file) ? await readFile(file, 'utf8') : '');
|
|
221
|
+
ensureMap(doc, ['agent'], 'gcf/config.yml: agent must be a mapping');
|
|
222
|
+
ensureMap(doc, ['agent', 'groups'], GROUPS_SHAPE);
|
|
229
223
|
const current = agentOf({ agent: doc.toJS().agent });
|
|
230
224
|
checkChange(change, current);
|
|
231
225
|
for (const id of change.on ?? []) doc.setIn(['agent', 'groups', id], true);
|
package/lib/audit-core.js
CHANGED
|
@@ -11,6 +11,7 @@ import { STORED } from './transitions.js';
|
|
|
11
11
|
import { openDepends } from './work-ops.js';
|
|
12
12
|
import { runHolder } from './main-lock.js';
|
|
13
13
|
import { sessionActive } from './session-mark.js';
|
|
14
|
+
import { fieldOf } from './question-fields.js';
|
|
14
15
|
|
|
15
16
|
function git(args, cwd) {
|
|
16
17
|
// A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
|
|
@@ -276,7 +277,7 @@ const questionsText = async (root) => {
|
|
|
276
277
|
return existsSync(file) ? readFile(file, 'utf8') : '';
|
|
277
278
|
};
|
|
278
279
|
|
|
279
|
-
const D2_UNTIL_RE =
|
|
280
|
+
const D2_UNTIL_RE = /^(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))/;
|
|
280
281
|
|
|
281
282
|
/**
|
|
282
283
|
* Until annotations across work items and systems docs, and the `until:` of each D2 record in
|
|
@@ -296,8 +297,8 @@ function untilRecords(items, systems, fired, questions) {
|
|
|
296
297
|
|
|
297
298
|
for (const rec of questions.replace(/\r\n/g, '\n').split(/\n(?=- id:)/)) {
|
|
298
299
|
const id = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
299
|
-
const trigger = rec
|
|
300
|
-
if (!id || !trigger ||
|
|
300
|
+
const trigger = fieldOf(rec, 'until')?.match(D2_UNTIL_RE)?.[1];
|
|
301
|
+
if (!id || !trigger || !/^D2\b/.test(fieldOf(rec, 'class') ?? '')) continue;
|
|
301
302
|
until.push({
|
|
302
303
|
file: 'gcf/open-questions.md',
|
|
303
304
|
id,
|
package/lib/board.js
CHANGED
|
@@ -231,7 +231,7 @@ export async function board(projectDir) {
|
|
|
231
231
|
return {
|
|
232
232
|
schema: BOARD_SCHEMA,
|
|
233
233
|
run_mode: config.run_mode,
|
|
234
|
-
// What the harness hands its agent runs (Harness-PRD §7):
|
|
234
|
+
// What the harness hands its agent runs (Harness-PRD §7): deny rules and the context limits.
|
|
235
235
|
agent: agentOf(config),
|
|
236
236
|
// The human's settings of config.yml, each with its value and default (`gcf config set`).
|
|
237
237
|
settings: settingsOf(await readRawConfig(root)),
|
package/lib/config-set.js
CHANGED
|
@@ -16,13 +16,31 @@ import { configOf, DEFAULT_CONFIG, gcfRoot, RUN_MODES } from './gcf-paths.js';
|
|
|
16
16
|
// Thrown inside the move to leave without a write or a commit.
|
|
17
17
|
const NO_CHANGE = new Error('no change');
|
|
18
18
|
|
|
19
|
+
/** gcf/config.yml as a YAML document with a map on top: an edit keeps its comments. */
|
|
20
|
+
export function configDoc(text) {
|
|
21
|
+
const doc = YAML.parseDocument(text);
|
|
22
|
+
if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
|
|
23
|
+
if (!YAML.isMap(doc.contents)) doc.contents = /** @type {any} */ (doc.createNode({}));
|
|
24
|
+
|
|
25
|
+
return doc;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Make `keys` a map. A blank key (`dod:` with nothing or only comments under it) is none yet. */
|
|
29
|
+
export function ensureMap(doc, keys, error) {
|
|
30
|
+
const node = doc.getIn(keys, true);
|
|
31
|
+
if (YAML.isMap(node)) return;
|
|
32
|
+
if (node != null && !(YAML.isScalar(node) && node.value == null)) throw new Error(error);
|
|
33
|
+
doc.setIn(keys, doc.createNode({}));
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
/** @type {{ key: string, kind: Kind, label: string }[]} */
|
|
20
37
|
export const SETTINGS = [
|
|
21
38
|
{
|
|
22
39
|
key: 'run_mode',
|
|
23
40
|
kind: 'mode',
|
|
24
41
|
label:
|
|
25
|
-
'how a run drives an item: worktree (several at once) or main (one at a time in the
|
|
42
|
+
'how a run drives an item: worktree (several at once) or main (one at a time in the ' +
|
|
43
|
+
'project folder)',
|
|
26
44
|
},
|
|
27
45
|
{ key: 'dod.test', kind: 'command', label: "the project's test command for the DoD, or none" },
|
|
28
46
|
{ key: 'dod.build', kind: 'command', label: "the project's build command for the DoD, or none" },
|
|
@@ -99,11 +117,13 @@ function valueOf(setting, text) {
|
|
|
99
117
|
|
|
100
118
|
return t;
|
|
101
119
|
}
|
|
120
|
+
|
|
102
121
|
if (setting.kind === 'mode') {
|
|
103
122
|
if (!RUN_MODES.includes(t)) throw new Error(`run_mode must be ${RUN_MODES.join(' | ')}`);
|
|
104
123
|
|
|
105
124
|
return t;
|
|
106
125
|
}
|
|
126
|
+
|
|
107
127
|
if (!t) return null;
|
|
108
128
|
// Plain decimals only: Number() would also take 0x10, 1e3 and 0b1.
|
|
109
129
|
if (!/^\d+(\.\d+)?$/.test(t)) throw new Error(`${setting.key} must be a number, got ${t}`);
|
|
@@ -130,20 +150,9 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
|
|
|
130
150
|
files: [file],
|
|
131
151
|
mutate: async () => {
|
|
132
152
|
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
133
|
-
const doc =
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
// A blank parent (`dod:` with nothing under it) is no map yet.
|
|
137
|
-
if (keys.length > 1) {
|
|
138
|
-
const parent = doc.get(keys[0], true);
|
|
139
|
-
if (
|
|
140
|
-
parent != null &&
|
|
141
|
-
!YAML.isMap(parent) &&
|
|
142
|
-
!(YAML.isScalar(parent) && parent.value == null)
|
|
143
|
-
)
|
|
144
|
-
throw new Error(`gcf/config.yml: ${keys[0]} must map its keys`);
|
|
145
|
-
if (!YAML.isMap(parent)) doc.set(keys[0], doc.createNode({}));
|
|
146
|
-
}
|
|
153
|
+
const doc = configDoc(text);
|
|
154
|
+
if (keys.length > 1)
|
|
155
|
+
ensureMap(doc, [keys[0]], `gcf/config.yml: ${keys[0]} must map its keys`);
|
|
147
156
|
if (value === null) doc.deleteIn(keys);
|
|
148
157
|
else doc.setIn(keys, value);
|
|
149
158
|
raw = doc.toJS();
|
|
@@ -154,6 +163,7 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
|
|
|
154
163
|
unchanged = true;
|
|
155
164
|
throw NO_CHANGE;
|
|
156
165
|
}
|
|
166
|
+
|
|
157
167
|
await atomicWrite(file, out);
|
|
158
168
|
},
|
|
159
169
|
commit,
|
package/lib/context.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listItems, listSystems, readConfig, tokensEst } from './gcf-paths.js';
|
|
5
|
+
import { fieldOf } from './question-fields.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
|
|
@@ -94,9 +95,9 @@ async function d2Assumptions(root, systems) {
|
|
|
94
95
|
const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
|
|
95
96
|
|
|
96
97
|
return records
|
|
97
|
-
.filter((rec) => /^\s*-?\s*id:/m.test(rec) &&
|
|
98
|
+
.filter((rec) => /^\s*-?\s*id:/m.test(rec) && /^D2\b/.test(fieldOf(rec, 'class') ?? ''))
|
|
98
99
|
.filter((rec) => {
|
|
99
|
-
const sys = rec
|
|
100
|
+
const sys = fieldOf(rec, 'sys') ?? '';
|
|
100
101
|
|
|
101
102
|
return systems.some((s) => sys.includes(s));
|
|
102
103
|
})
|
package/lib/design-check.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { buildLedger } from './ledger.js';
|
|
20
20
|
import { sourceBodies } from './anchors.js';
|
|
21
21
|
import { collapseLines } from './verdict-fs.js';
|
|
22
|
+
import { fieldOf } from './question-fields.js';
|
|
22
23
|
|
|
23
24
|
// Units a number may carry. A project adds its own in the `units:` block of glossary.md.
|
|
24
25
|
// Bare `px` is absent on purpose: its size depends on the screen density.
|
|
@@ -122,7 +123,7 @@ export const recordsIn = (text, key) =>
|
|
|
122
123
|
readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
123
124
|
|
|
124
125
|
/** A D1 record nobody answered yet: only such a question holds its units. */
|
|
125
|
-
const blocksUnits = (rec) =>
|
|
126
|
+
const blocksUnits = (rec) => /^D1\b/.test(fieldOf(rec, 'class') ?? '') && !fieldOf(rec, 'answer');
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
129
|
* open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. A
|
|
@@ -136,7 +137,7 @@ export function questionBlocks(text) {
|
|
|
136
137
|
const qids = new Set([...src.matchAll(/^\s*- id:\s*(\S+)/gm)].map((m) => m[1]));
|
|
137
138
|
for (const rec of src.split(/\n(?=- id:)/)) {
|
|
138
139
|
const qid = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
139
|
-
const ids = listOf(rec
|
|
140
|
+
const ids = listOf(fieldOf(rec, 'blocks') ?? '');
|
|
140
141
|
if (!qid || ids === 'TBD' || !ids.length) continue;
|
|
141
142
|
refs.push({ qid, ids });
|
|
142
143
|
if (!blocksUnits(rec)) continue;
|
package/lib/gcf-paths.js
CHANGED
|
@@ -68,6 +68,7 @@ export function configOf(parsed) {
|
|
|
68
68
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
69
69
|
throw new Error('gcf/config.yml must be a key: value mapping');
|
|
70
70
|
// A blank `key:` parses as null and means "not set", like an absent key.
|
|
71
|
+
/** @type {Record<string, any>} */
|
|
71
72
|
const config = {
|
|
72
73
|
...DEFAULT_CONFIG,
|
|
73
74
|
...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)),
|
|
@@ -81,6 +82,7 @@ export function configOf(parsed) {
|
|
|
81
82
|
);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
|
|
84
86
|
for (const key of PCT_KEYS)
|
|
85
87
|
if (config[key] > 100)
|
|
86
88
|
throw new Error(`gcf/config.yml: ${key} is a share of the window, 0-100, got ${config[key]}`);
|
package/lib/index-gen.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { listItems, listSystems, firstBullet } from './gcf-paths.js';
|
|
5
5
|
import { parseLive } from './live.js';
|
|
6
6
|
import { atomicWrite } from './safe-path.js';
|
|
7
|
+
import { fieldOf } from './question-fields.js';
|
|
7
8
|
import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -46,8 +47,9 @@ export async function generate(root) {
|
|
|
46
47
|
|
|
47
48
|
const txt = await readIfExists(root, 'open-questions.md');
|
|
48
49
|
if (txt !== null) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
50
|
+
const classes = txt.split(/\n(?=- id:)/).map((rec) => fieldOf(rec, 'class') ?? '');
|
|
51
|
+
const d1 = classes.filter((c) => /^D1\b/.test(c)).length;
|
|
52
|
+
const d2 = classes.filter((c) => /^D2\b/.test(c)).length;
|
|
51
53
|
row(['questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`]);
|
|
52
54
|
}
|
|
53
55
|
|
package/lib/main-lock.js
CHANGED
|
@@ -140,3 +140,14 @@ export async function withMainLock(projectDir, fn, { waitMs = 60000 } = {}) {
|
|
|
140
140
|
if (text.includes(token)) await unlink(file).catch(() => {});
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A committed move holds the main lock from its first read to its commit (Methodology §7): one
|
|
146
|
+
* that read the ledger before taking it would write over a move committed meanwhile.
|
|
147
|
+
* @template T
|
|
148
|
+
* @param {string} projectDir
|
|
149
|
+
* @param {boolean} commit
|
|
150
|
+
* @param {() => Promise<T>} move
|
|
151
|
+
*/
|
|
152
|
+
export const locked = (projectDir, commit, move) =>
|
|
153
|
+
commit ? withMainLock(projectDir, move) : move();
|
package/lib/phase.js
CHANGED
|
@@ -8,6 +8,7 @@ import { readLiveStrict, writeLive, liveFile } from './live.js';
|
|
|
8
8
|
import { buildLedger } from './ledger.js';
|
|
9
9
|
import { ITEM_TYPES } from './validate-item.js';
|
|
10
10
|
import { commitMove } from './unit-ops.js';
|
|
11
|
+
import { locked } from './main-lock.js';
|
|
11
12
|
|
|
12
13
|
const ALL = ITEM_TYPES;
|
|
13
14
|
const SOFT = ['mechanic', 'resource'];
|
|
@@ -206,8 +207,12 @@ export async function phaseStatus(projectDir) {
|
|
|
206
207
|
* The operator moves the phase on, commit `gcf(phase): <from> → <to>`. An unmet criterion needs
|
|
207
208
|
* `reason`, which the commit carries; the move itself is the operator's word on one that only
|
|
208
209
|
* the operator can check.
|
|
210
|
+
* @type {typeof nextPhaseUnlocked}
|
|
209
211
|
*/
|
|
210
|
-
export
|
|
212
|
+
export const nextPhase = (projectDir, opts = {}) =>
|
|
213
|
+
locked(projectDir, opts.commit ?? true, () => nextPhaseUnlocked(projectDir, opts));
|
|
214
|
+
|
|
215
|
+
async function nextPhaseUnlocked(projectDir, { reason = '', commit = true } = {}) {
|
|
211
216
|
const status = await phaseStatus(projectDir);
|
|
212
217
|
if (!status.next) throw new Error(`${status.phase} is the last phase`);
|
|
213
218
|
const unmet = status.criteria.filter((c) => c.met === false);
|
package/lib/pillars.js
CHANGED
|
@@ -10,6 +10,7 @@ import { recordsIn } from './design-check.js';
|
|
|
10
10
|
import { buildLedger } from './ledger.js';
|
|
11
11
|
import { atomicWrite } from './safe-path.js';
|
|
12
12
|
import { commitMove } from './unit-ops.js';
|
|
13
|
+
import { locked } from './main-lock.js';
|
|
13
14
|
|
|
14
15
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
16
|
|
|
@@ -73,7 +74,7 @@ async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
|
|
76
|
-
|
|
77
|
+
async function renamePillarUnlocked(projectDir, from, to, { commit = true } = {}) {
|
|
77
78
|
if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
|
|
78
79
|
const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
|
|
79
80
|
if (recordsIn(text, 'pillars').some((p) => p.id === to))
|
|
@@ -92,7 +93,7 @@ export async function renamePillar(projectDir, from, to, { commit = true } = {})
|
|
|
92
93
|
* unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
|
|
93
94
|
* it one, or decides it is out of scope. The reason is kept in the commit.
|
|
94
95
|
*/
|
|
95
|
-
|
|
96
|
+
async function retirePillarUnlocked(projectDir, id, { reason, commit = true }) {
|
|
96
97
|
const why = String(reason ?? '')
|
|
97
98
|
.replace(/\s+/g, ' ')
|
|
98
99
|
.trim();
|
|
@@ -111,3 +112,11 @@ export async function retirePillar(projectDir, id, { reason, commit = true }) {
|
|
|
111
112
|
commit,
|
|
112
113
|
});
|
|
113
114
|
}
|
|
115
|
+
|
|
116
|
+
// PILLARS.md and the citing files are read for the edit: the lock holds from that read on.
|
|
117
|
+
/** @type {typeof renamePillarUnlocked} */
|
|
118
|
+
export const renamePillar = (projectDir, from, to, opts = {}) =>
|
|
119
|
+
locked(projectDir, opts.commit ?? true, () => renamePillarUnlocked(projectDir, from, to, opts));
|
|
120
|
+
/** @type {typeof retirePillarUnlocked} */
|
|
121
|
+
export const retirePillar = (projectDir, id, opts) =>
|
|
122
|
+
locked(projectDir, opts.commit ?? true, () => retirePillarUnlocked(projectDir, id, opts));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The fields of one record of gcf/open-questions.md (`- id:` and the indented lines under it).
|
|
2
|
+
// A field is a line at the record's field indent; a YAML block (`|`, `>`) is the deeper lines
|
|
3
|
+
// under its key, so a block line that reads like `answer: x` is text, not a field.
|
|
4
|
+
|
|
5
|
+
/** The indent of a record's fields: the indent of its first field line. */
|
|
6
|
+
export const indentOf = (record) => record.match(/^([ \t]+)\w+:/m)?.[1] ?? ' ';
|
|
7
|
+
|
|
8
|
+
/** The index after the lines under field line `i`: deeper ones, and blank ones between them. */
|
|
9
|
+
export function blockEnd(lines, i, indent) {
|
|
10
|
+
let end = i + 1;
|
|
11
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
12
|
+
if (!lines[j].trim()) continue;
|
|
13
|
+
if (!lines[j].startsWith(indent) || !/^[ \t]/.test(lines[j].slice(indent.length))) break;
|
|
14
|
+
end = j + 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return end;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `|`, `>-`, `|2`, `> # note`: a block's sign with its chomping, indentation and comment.
|
|
21
|
+
const BLOCK = /^([|>])[-+]?\d?[-+]?(\s+#.*)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `>` block's lines folded: the lines of one paragraph join with a space, each blank line is a
|
|
25
|
+
* line break, and a more indented line keeps its breaks, the one before a blank line too.
|
|
26
|
+
*/
|
|
27
|
+
function folded(lines) {
|
|
28
|
+
let text = '';
|
|
29
|
+
/** @type {'text' | 'more' | null} */
|
|
30
|
+
let prev = null;
|
|
31
|
+
let blanks = 0;
|
|
32
|
+
for (const l of lines) {
|
|
33
|
+
if (!l) {
|
|
34
|
+
blanks += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const kind = /^[ \t]/.test(l) ? 'more' : 'text';
|
|
39
|
+
if (prev !== null) {
|
|
40
|
+
const both = prev === 'text' && kind === 'text';
|
|
41
|
+
if (blanks) text += '\n'.repeat(both ? blanks : blanks + 1);
|
|
42
|
+
else text += both ? ' ' : '\n';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
text += l;
|
|
46
|
+
prev = kind;
|
|
47
|
+
blanks = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The value of field `k` in a record, or null. A block reads as its lines, the common indent
|
|
55
|
+
* taken off: `|` keeps the line breaks, `>` folds them (`folded`).
|
|
56
|
+
*/
|
|
57
|
+
export function fieldOf(record, k) {
|
|
58
|
+
const lines = record.split(/\r?\n/);
|
|
59
|
+
const indent = indentOf(record);
|
|
60
|
+
const i = lines.findIndex((l) => l.startsWith(`${indent}${k}:`));
|
|
61
|
+
if (i < 0) return null;
|
|
62
|
+
const value = lines[i].slice(indent.length + k.length + 1).trim();
|
|
63
|
+
const block = value.match(BLOCK)?.[1];
|
|
64
|
+
if (!block) return value || null;
|
|
65
|
+
const body = lines.slice(i + 1, blockEnd(lines, i, indent));
|
|
66
|
+
const cut = Math.min(...body.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length));
|
|
67
|
+
const dedented = body.map((l) => l.slice(cut).trimEnd());
|
|
68
|
+
const text = block === '|' ? dedented.join('\n') : folded(dedented);
|
|
69
|
+
|
|
70
|
+
return text.trim() || null;
|
|
71
|
+
}
|
package/lib/questions.js
CHANGED
|
@@ -7,6 +7,8 @@ import { existsSync } from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { atomicWrite } from './safe-path.js';
|
|
9
9
|
import { commitMove } from './unit-ops.js';
|
|
10
|
+
import { locked } from './main-lock.js';
|
|
11
|
+
import { blockEnd, fieldOf, indentOf } from './question-fields.js';
|
|
10
12
|
|
|
11
13
|
// A D2 carries its assumption and the trigger that brings it back; `lens`, `sections` and `sys`
|
|
12
14
|
// say where a record came from: an import names its lens and the GDD sections.
|
|
@@ -27,19 +29,26 @@ const fileOf = (root) => path.join(root, 'open-questions.md');
|
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The records of a file's text, each `{id, text, start}`: a `- id:` line and the indented lines
|
|
30
|
-
* under it.
|
|
31
|
-
* part of it.
|
|
32
|
+
* under it. An unindented line ends the record, and so does a blank one unless an indented line
|
|
33
|
+
* follows it (a blank line inside a block): a heading after the last record is not part of it.
|
|
32
34
|
*/
|
|
33
35
|
function recordsOf(text) {
|
|
34
36
|
const records = [];
|
|
35
37
|
let at = 0;
|
|
36
38
|
/** @type {{id: string, text: string, start: number} | null} */
|
|
37
39
|
let rec = null;
|
|
40
|
+
// Blank lines after a record's last line: its own when an indented line follows.
|
|
41
|
+
let blank = '';
|
|
38
42
|
for (const line of text.split(/(?<=\n)/)) {
|
|
39
43
|
const id = line.match(/^- id:\s*(\S+)/)?.[1];
|
|
40
44
|
if (id) records.push((rec = { id, text: line, start: at }));
|
|
41
|
-
else if (rec && /^[ \t]+\S/.test(line)) rec.text += line;
|
|
42
|
-
else rec
|
|
45
|
+
else if (rec && /^[ \t]+\S/.test(line)) rec.text += blank + line;
|
|
46
|
+
else if (rec && !line.trim()) {
|
|
47
|
+
blank += line;
|
|
48
|
+
at += line.length;
|
|
49
|
+
continue;
|
|
50
|
+
} else rec = null;
|
|
51
|
+
blank = '';
|
|
43
52
|
at += line.length;
|
|
44
53
|
}
|
|
45
54
|
|
|
@@ -56,23 +65,27 @@ export async function listQuestions(root) {
|
|
|
56
65
|
|
|
57
66
|
return recordsOf(await readFile(file, 'utf8')).map(({ id, text }) => ({
|
|
58
67
|
id,
|
|
59
|
-
...Object.fromEntries(
|
|
60
|
-
FIELDS.map((k) => [
|
|
61
|
-
k,
|
|
62
|
-
text.match(new RegExp(`^[ \\t]+${k}:[ \\t]*(.*?)\\s*$`, 'm'))?.[1] || null,
|
|
63
|
-
]),
|
|
64
|
-
),
|
|
68
|
+
...Object.fromEntries(FIELDS.map((k) => [k, fieldOf(text, k)])),
|
|
65
69
|
}));
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** The record text with its `answer:` set: an old answer is replaced, a new one appended. */
|
|
69
73
|
function withAnswer(record, answer) {
|
|
70
74
|
const eol = record.includes('\r\n') ? '\r\n' : '\n';
|
|
71
|
-
const indent = record
|
|
75
|
+
const indent = indentOf(record);
|
|
72
76
|
const line = `${indent}answer: ${answer}`;
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
// Each line with its own line end, so the lines around the answer keep their bytes.
|
|
78
|
+
const parts = record.split(/(?<=\n)/);
|
|
79
|
+
const lines = parts.map((p) => p.replace(/\r?\n$/, ''));
|
|
80
|
+
const at = lines.findIndex((l) => l.startsWith(`${indent}answer:`));
|
|
81
|
+
// An old answer goes with the block under it, if it was one.
|
|
82
|
+
if (at >= 0) {
|
|
83
|
+
const end = blockEnd(lines, at, indent);
|
|
84
|
+
parts.splice(at, end - at, line + (parts[end - 1].match(/\r?\n$/)?.[0] ?? ''));
|
|
85
|
+
|
|
86
|
+
return parts.join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
const body = record.replace(/(\r?\n)+$/, '');
|
|
77
90
|
|
|
78
91
|
return `${body}${eol}${line}${record.slice(body.length) || eol}`;
|
|
@@ -81,8 +94,12 @@ function withAnswer(record, answer) {
|
|
|
81
94
|
/**
|
|
82
95
|
* The human answers question `qid` (`gcf question answer`): the answer goes into its record, one
|
|
83
96
|
* line, and commits as `gcf(answer): <qid>`. Refuses an id the file does not hold.
|
|
97
|
+
* @type {typeof answerUnlocked}
|
|
84
98
|
*/
|
|
85
|
-
export
|
|
99
|
+
export const answerQuestion = (projectDir, qid, answer, opts = {}) =>
|
|
100
|
+
locked(projectDir, opts.commit ?? true, () => answerUnlocked(projectDir, qid, answer, opts));
|
|
101
|
+
|
|
102
|
+
async function answerUnlocked(projectDir, qid, answer, { commit = true } = {}) {
|
|
86
103
|
const text = String(answer ?? '')
|
|
87
104
|
.replace(/\s+/g, ' ')
|
|
88
105
|
.trim();
|
package/lib/unit-ops.js
CHANGED
|
@@ -16,7 +16,7 @@ import { upsertBlock, openSystemDoc } from './systems-doc.js';
|
|
|
16
16
|
import { buryLine, recordLine } from './verdict-fs.js';
|
|
17
17
|
import { DEFAULT_GATE } from './validate-item.js';
|
|
18
18
|
import { refuseByPhase } from './phase.js';
|
|
19
|
-
import { withMainLock } from './main-lock.js';
|
|
19
|
+
import { locked, withMainLock } from './main-lock.js';
|
|
20
20
|
|
|
21
21
|
/** A design file's text without the `## <id>` section. */
|
|
22
22
|
export function removeUnit(text, id) {
|
|
@@ -265,8 +265,12 @@ async function record(projectDir, message, { files, mutate, commit, inputs = []
|
|
|
265
265
|
/**
|
|
266
266
|
* A hard unit is built and its tests are green: it leaves design/ without a work item. A line
|
|
267
267
|
* every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
|
|
268
|
+
* @type {typeof doneUnitUnlocked}
|
|
268
269
|
*/
|
|
269
|
-
export
|
|
270
|
+
export const doneUnit = (projectDir, id, opts = {}) =>
|
|
271
|
+
locked(projectDir, opts.commit ?? true, () => doneUnitUnlocked(projectDir, id, opts));
|
|
272
|
+
|
|
273
|
+
async function doneUnitUnlocked(projectDir, id, { constraints = [], commit = true } = {}) {
|
|
270
274
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
271
275
|
const unit = await designUnit(projectDir, id);
|
|
272
276
|
if (CARD_TYPES.includes(unit.header.type))
|
|
@@ -300,8 +304,14 @@ export async function addConstraints(doc, id, constraints) {
|
|
|
300
304
|
await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
|
|
301
305
|
}
|
|
302
306
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
307
|
+
/**
|
|
308
|
+
* A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9).
|
|
309
|
+
* @type {typeof dropUnitUnlocked}
|
|
310
|
+
*/
|
|
311
|
+
export const dropUnit = (projectDir, id, opts) =>
|
|
312
|
+
locked(projectDir, opts.commit ?? true, () => dropUnitUnlocked(projectDir, id, opts));
|
|
313
|
+
|
|
314
|
+
async function dropUnitUnlocked(projectDir, id, { reason, tags = [], commit = true }) {
|
|
305
315
|
if (!String(reason ?? '').trim())
|
|
306
316
|
throw new Error('a drop needs a reason: the graveyard keeps it');
|
|
307
317
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
package/lib/verify.js
CHANGED
|
@@ -20,7 +20,8 @@ export function runDod(projectDir, config, key, stdio) {
|
|
|
20
20
|
ok: false,
|
|
21
21
|
problems: [`dod.${key} is not set in gcf/config.yml — the project's command, or none`],
|
|
22
22
|
};
|
|
23
|
-
// shortcut: no timeout, a command that
|
|
23
|
+
// shortcut: no timeout; stdin is closed, but a command that never ends (a watch mode, a
|
|
24
|
+
// server) hangs `gcf verify`, and a harness run until its own timeout; add one when it bites.
|
|
24
25
|
const res = spawnSync(cmd, { cwd: projectDir, shell: true, stdio });
|
|
25
26
|
const ok = res.status === 0;
|
|
26
27
|
const why = res.error ? res.error.message : `exit ${res.status ?? res.signal}`;
|
package/lib/work-ops.js
CHANGED
|
@@ -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 {
|
|
40
|
+
import { locked, runHolder } from './main-lock.js';
|
|
41
41
|
import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -954,16 +954,6 @@ async function newItemUnlocked(projectDir, spec) {
|
|
|
954
954
|
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
-
/**
|
|
958
|
-
* A committed move holds the main lock from its first read to its commit (Methodology §7): one
|
|
959
|
-
* that read the ledger before taking it would write over a move committed meanwhile.
|
|
960
|
-
* @template T
|
|
961
|
-
* @param {string} projectDir
|
|
962
|
-
* @param {boolean} commit
|
|
963
|
-
* @param {() => Promise<T>} move
|
|
964
|
-
*/
|
|
965
|
-
const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
|
|
966
|
-
|
|
967
957
|
/** @type {typeof submitItemUnlocked} */
|
|
968
958
|
export const submitItem = (projectDir, id, opts = {}) =>
|
|
969
959
|
locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
|