gcf-aif-ext 0.7.6 → 0.7.8

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 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.8
54
+
55
+ - 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:
56
+ - 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;
57
+ - a blank line inside a block no longer ends the record: before, the fields after it were lost;
58
+ - `gcf question answer` replaces an old answer written as a block whole: before, it replaced only the block's first line;
59
+ - an empty `answer:` is no answer: `design check` now holds the D1's units, as the board already showed it unanswered.
60
+
61
+ ### 0.7.7
62
+
63
+ - `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`.
64
+ - `gcf board --json` questions also carry `assumed`, `until`, `sys`, `lens` and `sections`: a D2 shows what it assumed and when it comes back, and every record where it came from.
65
+
53
66
  ### 0.7.6
54
67
 
55
68
  - `ai-factory gcf permissions [--on <group>] [--off <group>] [--deny <rule>] [--undeny <rule>]` — what agent runs may not do: deny groups and the human's own rules in the `agent:` section of `gcf/config.yml`, one `gcf(permissions)` commit; with no change it lists them. On by default: `git-destroy`, `mass-delete`, `publish`, `human-moves`, `guards`; off: `secrets`, `outside`, `processes`. `agent.groups` keeps only the human's choices (`secrets: true`): a group it does not name keeps its default, so one a later version adds comes on. `git push` is in none. `gcf board --json` carries `agent` (`groups`, `deny_extra`, the resolved `deny`, `allow`).
package/commands/gcf.js CHANGED
@@ -977,6 +977,37 @@ function registerBoard(gcf) {
977
977
  ].join('\n'),
978
978
  );
979
979
  });
980
+ const config = gcf
981
+ .command('config')
982
+ .description("The human's settings of gcf/config.yml: list them, or set one (commit)")
983
+ // A misspelt subcommand would read as a stray word and list the settings, as if set.
984
+ .allowExcessArguments(false)
985
+ .option('--json', 'machine output')
986
+ .action(async (o) => {
987
+ const { gcfRoot, readConfig, readRawConfig } = await lib('gcf-paths');
988
+ await readConfig(gcfRoot()); // a config the rules refuse says so here
989
+ const list = (await lib('config-set')).settingsOf(await readRawConfig(gcfRoot()));
990
+ out(o.json, list, (x) =>
991
+ x.map((s) => `${s.key}: ${s.value ?? '-'} # ${s.label}`).join('\n'),
992
+ );
993
+ });
994
+ config
995
+ .command('set <key>')
996
+ .description('Set one key; an empty --value= takes a number back to its default')
997
+ // The value only through --value: `set plan_passes 7` must not read as a reset. A value
998
+ // may start with a hyphen (a command's flag); as `--value=<text>` it stays one value.
999
+ .allowExcessArguments(false)
1000
+ .requiredOption('--value <value>', 'the new value; empty (--value=) for the default')
1001
+ .option('--no-commit', 'skip the commit (tests only)')
1002
+ .action(async (key, o) => {
1003
+ const r = await (
1004
+ await lib('config-set')
1005
+ ).setConfig(process.cwd(), key, o.value, {
1006
+ commit: o.commit,
1007
+ });
1008
+ const now = `${r.key}: ${r.value ?? 'default'}`;
1009
+ console.log(r.unchanged ? `${now} (unchanged)` : `${now}${shortHash(r.hash)}`);
1010
+ });
980
1011
  const many = (v, prev = []) => [...prev, v];
981
1012
  gcf
982
1013
  .command('permissions')
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.7.6",
4
+ "version": "0.7.8",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -75,7 +75,7 @@ export const DENY_GROUPS = [
75
75
  {
76
76
  id: 'human-moves',
77
77
  label:
78
- "the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, permissions, setup",
78
+ "the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings, permissions, setup",
79
79
  on: true,
80
80
  rules: shell(
81
81
  'ai-factory gcf verdict*',
@@ -85,6 +85,7 @@ export const DENY_GROUPS = [
85
85
  'ai-factory gcf phase next*',
86
86
  'ai-factory gcf pillar*',
87
87
  'ai-factory gcf permissions*',
88
+ 'ai-factory gcf config set*',
88
89
  'ai-factory gcf hooks-install*',
89
90
  'ai-factory gcf-harness init*',
90
91
  'ai-factory gcf-harness run*',
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 = /^\s+until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))/m;
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.match(D2_UNTIL_RE)?.[1];
300
- if (!id || !trigger || !/^\s+class:\s*D2\b/m.test(rec)) continue;
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
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { buildLedger } from './ledger.js';
7
- import { readConfig } from './gcf-paths.js';
7
+ import { readConfig, readRawConfig } from './gcf-paths.js';
8
8
  import { readLive } from './live.js';
9
9
  import { listInbox } from './inbox.js';
10
10
  import { phaseStatus } from './phase.js';
@@ -17,6 +17,7 @@ import { movesOf } from './transitions.js';
17
17
  import { listQuestions } from './questions.js';
18
18
  import { sessionActive } from './session-mark.js';
19
19
  import { agentOf } from './agent-permissions.js';
20
+ import { settingsOf } from './config-set.js';
20
21
 
21
22
  // The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
22
23
  // when a field changes meaning or goes away; a new field does not bump it.
@@ -232,6 +233,8 @@ export async function board(projectDir) {
232
233
  run_mode: config.run_mode,
233
234
  // What the harness hands its agent runs (Harness-PRD §7): the deny rules and the context limits.
234
235
  agent: agentOf(config),
236
+ // The human's settings of config.yml, each with its value and default (`gcf config set`).
237
+ settings: settingsOf(await readRawConfig(root)),
235
238
  context: {
236
239
  handoff_pct: config.context_handoff_pct,
237
240
  window: config.context_window,
@@ -0,0 +1,171 @@
1
+ // `gcf config set` (GCF-PRD §12.4): the human's settings of gcf/config.yml, one key at a time,
2
+ // so a board edits them through the core and never writes the file itself. A change is checked
3
+ // by the same rules a read applies before it is written; the rest of the file keeps its keys
4
+ // and comments. `phases:` and `agent:` have their own commands and are not set here.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import YAML from 'yaml';
10
+ import { commitMove } from './unit-ops.js';
11
+ import { atomicWrite } from './safe-path.js';
12
+ import { configOf, DEFAULT_CONFIG, gcfRoot, RUN_MODES } from './gcf-paths.js';
13
+
14
+ /** @typedef {'number' | 'share' | 'mode' | 'command'} Kind */
15
+
16
+ // Thrown inside the move to leave without a write or a commit.
17
+ const NO_CHANGE = new Error('no change');
18
+
19
+ /** @type {{ key: string, kind: Kind, label: string }[]} */
20
+ export const SETTINGS = [
21
+ {
22
+ key: 'run_mode',
23
+ kind: 'mode',
24
+ label:
25
+ 'how a run drives an item: worktree (several at once) or main (one at a time in the project folder)',
26
+ },
27
+ { key: 'dod.test', kind: 'command', label: "the project's test command for the DoD, or none" },
28
+ { key: 'dod.build', kind: 'command', label: "the project's build command for the DoD, or none" },
29
+ {
30
+ key: 'plan_passes',
31
+ kind: 'number',
32
+ label: 'improve passes a plan may take before the item blocks',
33
+ },
34
+ { key: 'dod_attempts', kind: 'number', label: 'DoD attempts before the item blocks' },
35
+ {
36
+ key: 'wip_built_limit',
37
+ kind: 'number',
38
+ label: 'items waiting for a verdict before new verdict work stops',
39
+ },
40
+ {
41
+ key: 'context_handoff_pct',
42
+ kind: 'share',
43
+ label:
44
+ 'share of the context window at which a session hands its item to a fresh one (0: never)',
45
+ },
46
+ { key: 'context_window', kind: 'number', label: "context window in tokens (0: the model's)" },
47
+ {
48
+ key: 'auto_compact_pct',
49
+ kind: 'share',
50
+ label: 'share at which the agent compacts (0: its default; at least the handoff share + 15)',
51
+ },
52
+ { key: 'tune_limit', kind: 'number', label: 'tune verdicts an item may take' },
53
+ { key: 'unplayed_warn_after', kind: 'number', label: 'unplayed items before the audit warns' },
54
+ {
55
+ key: 'built_stale_after_commits',
56
+ kind: 'number',
57
+ label: 'commits after which a waiting or blocked item is stale',
58
+ },
59
+ {
60
+ key: 'question_stale_after_commits',
61
+ kind: 'number',
62
+ label: 'commits after which an open question is stale',
63
+ },
64
+ { key: 'context_budget', kind: 'number', label: "token budget of an item's readset" },
65
+ { key: 'pillars_budget', kind: 'number', label: 'token budget of PILLARS.md' },
66
+ {
67
+ key: 'envelope_default_spread',
68
+ kind: 'number',
69
+ label: 'default spread of a feel envelope around a GDD number (0.3: ±30%)',
70
+ },
71
+ ];
72
+
73
+ const BY_KEY = new Map(SETTINGS.map((s) => [s.key, s]));
74
+
75
+ /**
76
+ * Each setting with what the file sets (null: not set, the default holds) and its default:
77
+ * what a settings screen shows. `raw` is the parsed config.yml, before the defaults.
78
+ */
79
+ export function settingsOf(raw) {
80
+ return SETTINGS.map((s) => {
81
+ const [head, sub] = s.key.split('.');
82
+ const parent = raw?.[head];
83
+ const value = sub
84
+ ? parent && typeof parent === 'object'
85
+ ? (parent[sub] ?? null)
86
+ : null
87
+ : (parent ?? null);
88
+ const def = sub ? null : head === 'run_mode' ? 'worktree' : (DEFAULT_CONFIG[head] ?? null);
89
+
90
+ return { ...s, value, default: def };
91
+ });
92
+ }
93
+
94
+ /** The value of `text` for a setting of `kind`; an empty number goes back to its default. */
95
+ function valueOf(setting, text) {
96
+ const t = String(text ?? '').trim();
97
+ if (setting.kind === 'command') {
98
+ if (!t) throw new Error(`${setting.key}: name the command, or none`);
99
+
100
+ return t;
101
+ }
102
+
103
+ if (setting.kind === 'mode') {
104
+ if (!RUN_MODES.includes(t)) throw new Error(`run_mode must be ${RUN_MODES.join(' | ')}`);
105
+
106
+ return t;
107
+ }
108
+
109
+ if (!t) return null;
110
+ // Plain decimals only: Number() would also take 0x10, 1e3 and 0b1.
111
+ if (!/^\d+(\.\d+)?$/.test(t)) throw new Error(`${setting.key} must be a number, got ${t}`);
112
+ const n = Number(t);
113
+ if (!Number.isFinite(n)) throw new Error(`${setting.key} must be a number, got ${t}`);
114
+
115
+ return n;
116
+ }
117
+
118
+ /**
119
+ * Set one key of gcf/config.yml and commit gcf(config); a number set to nothing goes back to
120
+ * its default. The file is read and checked under the move's lock.
121
+ */
122
+ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
123
+ const setting = BY_KEY.get(key);
124
+ if (!setting) throw new Error(`no setting ${key}: ${SETTINGS.map((s) => s.key).join(' | ')}`);
125
+ const value = valueOf(setting, text);
126
+ const file = path.join(gcfRoot(projectDir), 'config.yml');
127
+ const keys = key.split('.');
128
+ let raw;
129
+ // A change that changes nothing (the same value, a reset of a key not set) commits nothing.
130
+ let unchanged = false;
131
+ const hash = await commitMove(projectDir, `gcf(config): ${key}`, {
132
+ files: [file],
133
+ mutate: async () => {
134
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
135
+ const doc = YAML.parseDocument(text);
136
+ if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
137
+ if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({});
138
+ // A blank parent (`dod:` with nothing under it) is no map yet.
139
+ if (keys.length > 1) {
140
+ const parent = doc.get(keys[0], true);
141
+ if (
142
+ parent != null &&
143
+ !YAML.isMap(parent) &&
144
+ !(YAML.isScalar(parent) && parent.value == null)
145
+ )
146
+ throw new Error(`gcf/config.yml: ${keys[0]} must map its keys`);
147
+ if (!YAML.isMap(parent)) doc.set(keys[0], doc.createNode({}));
148
+ }
149
+
150
+ if (value === null) doc.deleteIn(keys);
151
+ else doc.setIn(keys, value);
152
+ raw = doc.toJS();
153
+ configOf(raw);
154
+ const out = doc.toString();
155
+ // The values, not the text: YAML's output may differ in form (a final newline, `{}`).
156
+ if (isDeepStrictEqual(YAML.parse(text) ?? {}, raw ?? {})) {
157
+ unchanged = true;
158
+ throw NO_CHANGE;
159
+ }
160
+
161
+ await atomicWrite(file, out);
162
+ },
163
+ commit,
164
+ }).catch((e) => {
165
+ if (e !== NO_CHANGE) throw e;
166
+
167
+ return null;
168
+ });
169
+
170
+ return { key, value, unchanged, settings: settingsOf(raw), hash };
171
+ }
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) && /class:\s*D2/.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.match(/sys:\s*(.+)/)?.[1] ?? '';
100
+ const sys = fieldOf(rec, 'sys') ?? '';
100
101
 
101
102
  return systems.some((s) => sys.includes(s));
102
103
  })
@@ -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) => /^\s+class:\s*D1\b/m.test(rec) && !/^\s+answer:/m.test(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.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
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
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { parse } from './frontmatter.js';
6
6
 
7
- const DEFAULT_CONFIG = {
7
+ export const DEFAULT_CONFIG = {
8
8
  schema: 1,
9
9
  built_stale_after_commits: 5,
10
10
  tune_limit: 3,
@@ -48,27 +48,24 @@ function runModeOf(v = 'worktree') {
48
48
 
49
49
  /** @returns {Promise<Record<string, any>>} */
50
50
  export async function readConfig(root) {
51
- const config = await readNumbers(root);
52
- for (const key of PCT_KEYS)
53
- if (config[key] > 100)
54
- throw new Error(`gcf/config.yml: ${key} is a share of the window, 0-100, got ${config[key]}`);
55
- // A compaction before the handoff threshold shrinks the context the handoff waits for.
56
- const { auto_compact_pct: compact, context_handoff_pct: handoff } = config;
57
- if (compact && handoff && compact < handoff + 15)
58
- throw new Error(
59
- `gcf/config.yml: auto_compact_pct ${compact} must be at least context_handoff_pct + 15 ` +
60
- `(${handoff + 15}), or the compaction comes before the handoff`,
61
- );
62
-
63
- return { ...config, run_mode: runModeOf(config.run_mode) };
51
+ return configOf(await readRawConfig(root));
64
52
  }
65
53
 
66
- /** @returns {Promise<Record<string, any>>} */
67
- async function readNumbers(root) {
54
+ /** config.yml as the file has it, before the defaults: `{}` when there is none. */
55
+ export async function readRawConfig(root) {
68
56
  const file = path.join(root, 'config.yml');
69
- if (!existsSync(file)) return { ...DEFAULT_CONFIG };
70
- const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
71
- if (typeof parsed !== 'object' || Array.isArray(parsed))
57
+
58
+ return existsSync(file) ? (YAML.parse(await readFile(file, 'utf8')) ?? {}) : {};
59
+ }
60
+
61
+ /**
62
+ * The config a parsed config.yml gives, checked: defaults for what it does not set, every limit
63
+ * a non-negative number, the shares 0-100, the run mode one of RUN_MODES. Throws on the first
64
+ * key that breaks a rule. `gcf config set` checks a change with it before it writes.
65
+ * @returns {Record<string, any>}
66
+ */
67
+ export function configOf(parsed) {
68
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
72
69
  throw new Error('gcf/config.yml must be a key: value mapping');
73
70
  // A blank `key:` parses as null and means "not set", like an absent key.
74
71
  const config = {
@@ -85,7 +82,18 @@ async function readNumbers(root) {
85
82
  }
86
83
  }
87
84
 
88
- return config;
85
+ for (const key of PCT_KEYS)
86
+ if (config[key] > 100)
87
+ throw new Error(`gcf/config.yml: ${key} is a share of the window, 0-100, got ${config[key]}`);
88
+ // A compaction before the handoff threshold shrinks the context the handoff waits for.
89
+ const { auto_compact_pct: compact, context_handoff_pct: handoff } = config;
90
+ if (compact && handoff && compact < handoff + 15)
91
+ throw new Error(
92
+ `gcf/config.yml: auto_compact_pct ${compact} must be at least context_handoff_pct + 15 ` +
93
+ `(${handoff + 15}), or the compaction comes before the handoff`,
94
+ );
95
+
96
+ return { ...config, run_mode: runModeOf(config.run_mode) };
89
97
  }
90
98
 
91
99
  /** Token estimate: ceil(chars/4). Stability over accuracy (GCF-PRD §9.1). */
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 d1 = count(txt, /^\s*class:\s*D1/gm);
50
- const d2 = count(txt, /^\s*class:\s*D2/gm);
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
 
@@ -0,0 +1,70 @@
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
+ let prev = null;
30
+ let blanks = 0;
31
+ for (const l of lines) {
32
+ if (!l) {
33
+ blanks += 1;
34
+ continue;
35
+ }
36
+
37
+ const kind = /^[ \t]/.test(l) ? 'more' : 'text';
38
+ if (prev !== null) {
39
+ const both = prev === 'text' && kind === 'text';
40
+ if (blanks) text += '\n'.repeat(both ? blanks : blanks + 1);
41
+ else text += both ? ' ' : '\n';
42
+ }
43
+
44
+ text += l;
45
+ prev = kind;
46
+ blanks = 0;
47
+ }
48
+
49
+ return text;
50
+ }
51
+
52
+ /**
53
+ * The value of field `k` in a record, or null. A block reads as its lines, the common indent
54
+ * taken off: `|` keeps the line breaks, `>` folds them (`folded`).
55
+ */
56
+ export function fieldOf(record, k) {
57
+ const lines = record.split(/\r?\n/);
58
+ const indent = indentOf(record);
59
+ const i = lines.findIndex((l) => l.startsWith(`${indent}${k}:`));
60
+ if (i < 0) return null;
61
+ const value = lines[i].slice(indent.length + k.length + 1).trim();
62
+ const block = value.match(BLOCK)?.[1];
63
+ if (!block) return value || null;
64
+ const body = lines.slice(i + 1, blockEnd(lines, i, indent));
65
+ const cut = Math.min(...body.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length));
66
+ const dedented = body.map((l) => l.slice(cut).trimEnd());
67
+ const text = block === '|' ? dedented.join('\n') : folded(dedented);
68
+
69
+ return text.trim() || null;
70
+ }
package/lib/questions.js CHANGED
@@ -7,26 +7,47 @@ 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 { blockEnd, fieldOf, indentOf } from './question-fields.js';
10
11
 
11
- const FIELDS = ['class', 'question', 'proposed', 'blocks', 'answer'];
12
+ // A D2 carries its assumption and the trigger that brings it back; `lens`, `sections` and `sys`
13
+ // say where a record came from: an import names its lens and the GDD sections.
14
+ const FIELDS = [
15
+ 'class',
16
+ 'question',
17
+ 'proposed',
18
+ 'blocks',
19
+ 'answer',
20
+ 'assumed',
21
+ 'until',
22
+ 'sys',
23
+ 'lens',
24
+ 'sections',
25
+ ];
12
26
 
13
27
  const fileOf = (root) => path.join(root, 'open-questions.md');
14
28
 
15
29
  /**
16
30
  * The records of a file's text, each `{id, text, start}`: a `- id:` line and the indented lines
17
- * under it. A blank or unindented line ends the record: a heading after the last record is not
18
- * part of it.
31
+ * under it. An unindented line ends the record, and so does a blank one unless an indented line
32
+ * follows it (a blank line inside a block): a heading after the last record is not part of it.
19
33
  */
20
34
  function recordsOf(text) {
21
35
  const records = [];
22
36
  let at = 0;
23
37
  /** @type {{id: string, text: string, start: number} | null} */
24
38
  let rec = null;
39
+ // Blank lines after a record's last line: its own when an indented line follows.
40
+ let blank = '';
25
41
  for (const line of text.split(/(?<=\n)/)) {
26
42
  const id = line.match(/^- id:\s*(\S+)/)?.[1];
27
43
  if (id) records.push((rec = { id, text: line, start: at }));
28
- else if (rec && /^[ \t]+\S/.test(line)) rec.text += line;
29
- else rec = null;
44
+ else if (rec && /^[ \t]+\S/.test(line)) rec.text += blank + line;
45
+ else if (rec && !line.trim()) {
46
+ blank += line;
47
+ at += line.length;
48
+ continue;
49
+ } else rec = null;
50
+ blank = '';
30
51
  at += line.length;
31
52
  }
32
53
 
@@ -43,23 +64,27 @@ export async function listQuestions(root) {
43
64
 
44
65
  return recordsOf(await readFile(file, 'utf8')).map(({ id, text }) => ({
45
66
  id,
46
- ...Object.fromEntries(
47
- FIELDS.map((k) => [
48
- k,
49
- text.match(new RegExp(`^[ \\t]+${k}:[ \\t]*(.*?)\\s*$`, 'm'))?.[1] || null,
50
- ]),
51
- ),
67
+ ...Object.fromEntries(FIELDS.map((k) => [k, fieldOf(text, k)])),
52
68
  }));
53
69
  }
54
70
 
55
71
  /** The record text with its `answer:` set: an old answer is replaced, a new one appended. */
56
72
  function withAnswer(record, answer) {
57
73
  const eol = record.includes('\r\n') ? '\r\n' : '\n';
58
- const indent = record.match(/^([ \t]+)\w+:/m)?.[1] || ' ';
74
+ const indent = indentOf(record);
59
75
  const line = `${indent}answer: ${answer}`;
60
- // A function, not a string: `$&` in the operator's answer is text, not a pattern.
61
- if (/^[ \t]+answer:/m.test(record))
62
- return record.replace(/^[ \t]+answer:.*?(?=\r?$)/m, () => line);
76
+ // Each line with its own line end, so the lines around the answer keep their bytes.
77
+ const parts = record.split(/(?<=\n)/);
78
+ const lines = parts.map((p) => p.replace(/\r?\n$/, ''));
79
+ const at = lines.findIndex((l) => l.startsWith(`${indent}answer:`));
80
+ // An old answer goes with the block under it, if it was one.
81
+ if (at >= 0) {
82
+ const end = blockEnd(lines, at, indent);
83
+ parts.splice(at, end - at, line + (parts[end - 1].match(/\r?\n$/)?.[0] ?? ''));
84
+
85
+ return parts.join('');
86
+ }
87
+
63
88
  const body = record.replace(/(\r?\n)+$/, '');
64
89
 
65
90
  return `${body}${eol}${line}${record.slice(body.length) || eol}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@ gcf_forbidden:
29
29
  - do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
30
30
  - do not run `ai-factory gcf phase next`: the phase moves on the human's word only
31
31
  - do not change what agent runs may do: `ai-factory gcf permissions` and its deny groups belong to the human
32
+ - do not change a setting of `gcf/config.yml`: `ai-factory gcf config set` belongs to the human
32
33
  - do not reuse an ended id: rework gets a new id with `reopens: <old id>`
33
34
  - do not commit past the gate with `--no-verify`: a refused commit names what to fix
34
35