@walwal-harness/cli 7.1.25 → 7.1.27

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/bin/init.js CHANGED
@@ -527,7 +527,6 @@ function scaffoldHarness() {
527
527
  copyFile(srcPath, destPath);
528
528
  }
529
529
  }
530
-
531
530
  // Copy conventions — mirror gotchas preservation: never overwrite files with
532
531
  // accumulated `### [C-NNN]` entries.
533
532
  const conventionsSrc = path.join(PKG_ROOT, 'conventions');
@@ -550,7 +549,6 @@ function scaffoldHarness() {
550
549
  copyFile(srcPath, destPath);
551
550
  }
552
551
  }
553
-
554
552
  // First-install migration: extract Convention/Gotcha-shaped sections from
555
553
  // existing CLAUDE.md / AGENTS.md and copy into the hierarchical stores.
556
554
  if (isFirstInstall) {
@@ -564,6 +562,7 @@ function scaffoldHarness() {
564
562
  // Copy templates as initial files
565
563
  const templateMap = {
566
564
  'progress.json.template': path.join(HARNESS_DIR, 'progress.json'),
565
+ 'todos-state.json.template': path.join(HARNESS_DIR, 'todos', 'state.json'),
567
566
  };
568
567
 
569
568
  const templatesDir = path.join(PKG_ROOT, 'assets', 'templates');
@@ -571,13 +570,22 @@ function scaffoldHarness() {
571
570
  for (const [template, dest] of Object.entries(templateMap)) {
572
571
  const src = path.join(templatesDir, template);
573
572
  if (fs.existsSync(src) && (!fileExists(dest) || isForce)) {
573
+ ensureDir(path.dirname(dest));
574
574
  let content = fs.readFileSync(src, 'utf8');
575
575
  content = content.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
576
+ content = content.replace(/\{\{DATE_ISO\}\}/g, new Date().toISOString());
576
577
  fs.writeFileSync(dest, content);
577
578
  }
578
579
  }
579
580
  }
580
581
 
582
+ ensureDir(path.join(HARNESS_DIR, 'todos'));
583
+ for (const rel of ['events.jsonl', path.join('todos', 'events.jsonl')]) {
584
+ const p = path.join(HARNESS_DIR, rel);
585
+ if (!fileExists(p) || isForce) fs.writeFileSync(p, '');
586
+ }
587
+ ensureStructuredRuntimeFiles();
588
+
581
589
  // Migrate progress.json v1 → v2 (add mode + team_state fields)
582
590
  const progressPath = path.join(HARNESS_DIR, 'progress.json');
583
591
  if (fileExists(progressPath)) {
@@ -713,6 +721,38 @@ function ensureHarnessEnv() {
713
721
  log('.env: HARNESS_BASE_PORT placeholder added — set value with Owner approval before starting services');
714
722
  }
715
723
 
724
+ function ensureStructuredRuntimeFiles({ dryRun = false } = {}) {
725
+ const todosDir = path.join(HARNESS_DIR, 'todos');
726
+ const statePath = path.join(todosDir, 'state.json');
727
+ const eventsPath = path.join(HARNESS_DIR, 'events.jsonl');
728
+ const todoEventsPath = path.join(todosDir, 'events.jsonl');
729
+ const todoTplPath = path.join(PKG_ROOT, 'assets', 'templates', 'todos-state.json.template');
730
+
731
+ const missing = [eventsPath, statePath, todoEventsPath].filter((p) => !fileExists(p));
732
+ if (!missing.length) return false;
733
+
734
+ log(` structured runtime: ${missing.map((p) => path.relative(HARNESS_DIR, p)).join(', ')} 생성`);
735
+ if (dryRun) return true;
736
+
737
+ ensureDir(todosDir);
738
+ if (!fileExists(eventsPath)) fs.writeFileSync(eventsPath, '');
739
+ if (!fileExists(todoEventsPath)) fs.writeFileSync(todoEventsPath, '');
740
+ if (!fileExists(statePath)) {
741
+ let content;
742
+ if (fileExists(todoTplPath)) {
743
+ content = fs.readFileSync(todoTplPath, 'utf8').replace(/\{\{DATE_ISO\}\}/g, new Date().toISOString());
744
+ } else {
745
+ content = JSON.stringify({
746
+ version: 1,
747
+ updated_at: new Date().toISOString(),
748
+ owners: { ceo: [], coo: [], cdo: [], cto: [], cqo: [], ops: [] },
749
+ }, null, 2) + '\n';
750
+ }
751
+ fs.writeFileSync(statePath, content);
752
+ }
753
+ return true;
754
+ }
755
+
716
756
  // ─────────────────────────────────────────
717
757
  // 2. Skills → .claude/skills/
718
758
  // ─────────────────────────────────────────
@@ -1265,6 +1305,7 @@ function detectMigrationNeeded() {
1265
1305
  memoryMissingSystemEntries: [],
1266
1306
  gotchaMissingEntries: {}, // { "<filename>": [G-IDs...] }
1267
1307
  conventionMissingEntries: {}, // { "<filename>": [C-IDs...] }
1308
+ ruleRegistryLinks: [],
1268
1309
  bundleVersionStale: null, // { current, installed }
1269
1310
  };
1270
1311
 
@@ -1418,6 +1459,7 @@ function detectMigrationNeeded() {
1418
1459
  flags.memoryMissingSystemEntries = tplEntryIds.filter((id) => !userEntryIds.has(id));
1419
1460
  } catch {}
1420
1461
  }
1462
+ flags.ruleRegistryLinks = detectRuleRegistryLinks();
1421
1463
  return flags;
1422
1464
  }
1423
1465
 
@@ -1479,6 +1521,136 @@ function extractConventionEntryBlock(md, id) {
1479
1521
  return m ? m[1].trimEnd() : null;
1480
1522
  }
1481
1523
 
1524
+ const CANONICAL_RULE_SCOPES = new Set([
1525
+ 'README.md',
1526
+ 'shared.md',
1527
+ 'ceo.md',
1528
+ 'coo.md',
1529
+ 'cdo.md',
1530
+ 'cto.md',
1531
+ 'cqo.md',
1532
+ 'ops.md',
1533
+ 'hiring.md',
1534
+ 'resource-manager.md',
1535
+ 'brick-office.md',
1536
+ ]);
1537
+
1538
+ const CXX_RULE_LINK_MARKER = '<!-- walwal-harness:related-rule-links -->';
1539
+
1540
+ function collectKnownWorkerNames() {
1541
+ const names = new Set();
1542
+ const hrRoot = path.join(HARNESS_DIR, 'shared', 'HR-Resource');
1543
+ if (fs.existsSync(hrRoot)) {
1544
+ for (const entry of fs.readdirSync(hrRoot, { withFileTypes: true })) {
1545
+ if (entry.isDirectory()) names.add(entry.name);
1546
+ }
1547
+ }
1548
+ const rosterPath = path.join(HARNESS_DIR, 'shared', 'hr-roster.json');
1549
+ if (fs.existsSync(rosterPath)) {
1550
+ try {
1551
+ const roster = JSON.parse(fs.readFileSync(rosterPath, 'utf8'));
1552
+ for (const entry of Array.isArray(roster.hired) ? roster.hired : []) {
1553
+ if (entry?.worker) names.add(entry.worker);
1554
+ }
1555
+ } catch {}
1556
+ }
1557
+ return names;
1558
+ }
1559
+
1560
+ function classifyRuleRegistryFile(file, kind, knownWorkers, body = '') {
1561
+ const base = path.basename(file, '.md');
1562
+ const ext = path.extname(file);
1563
+ if (ext !== '.md') return null;
1564
+ if (LEGACY_V7_FILE_OWNER[file]) {
1565
+ return { targets: [path.basename(LEGACY_V7_FILE_OWNER[file], '.md')], reason: 'legacy-role' };
1566
+ }
1567
+ const haystack = `${base}\n${body}`.toLowerCase();
1568
+ const targets = new Set();
1569
+
1570
+ if (/\b(i18n|intl|locale|localization|translation|translate|multilingual|language|aria|rtl|l10n)\b/.test(haystack)) {
1571
+ targets.add('cto');
1572
+ targets.add('cqo');
1573
+ }
1574
+ if (/\b(ui|ux|design|brand|visual|layout|accessibility|a11y|responsive|color|typography)\b/.test(haystack)) {
1575
+ targets.add('cdo');
1576
+ targets.add('cqo');
1577
+ }
1578
+ if (/\b(api|backend|frontend|database|schema|auth|flutter|react|next|build|deploy|port|integration|migration)\b/.test(haystack)) {
1579
+ targets.add('cto');
1580
+ }
1581
+ if (/\b(test|qa|quality|regression|e2e|playwright|bug|hotfix|incident|failure|risk)\b/.test(haystack)) {
1582
+ targets.add('cqo');
1583
+ }
1584
+ if (/\b(ops|runtime|server|log|monitor|health|production|docker|port)\b/.test(haystack)) {
1585
+ targets.add('ops');
1586
+ }
1587
+ if (/\b(plan|research|market|hypothesis|requirement|product|scope)\b/.test(haystack)) {
1588
+ targets.add('coo');
1589
+ }
1590
+ if (knownWorkers.has(base)) {
1591
+ return { targets: ['coo', 'cto', 'cqo', 'ops'].filter(Boolean), reason: 'worker-topic' };
1592
+ }
1593
+ if (!targets.size) targets.add('shared');
1594
+ return { targets: [...targets], reason: targets.has('shared') ? 'unclassified-topic' : 'topic-keywords' };
1595
+ }
1596
+
1597
+ function detectRuleRegistryLinks() {
1598
+ const links = [];
1599
+ const knownWorkers = collectKnownWorkerNames();
1600
+ for (const kind of ['conventions', 'gotchas']) {
1601
+ const root = path.join(HARNESS_DIR, kind);
1602
+ if (!fs.existsSync(root)) continue;
1603
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
1604
+ if (entry.isDirectory()) continue;
1605
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
1606
+ if (CANONICAL_RULE_SCOPES.has(entry.name)) continue;
1607
+ const sourcePath = path.join(root, entry.name);
1608
+ const body = fs.existsSync(sourcePath) ? fs.readFileSync(sourcePath, 'utf8') : '';
1609
+ const classification = classifyRuleRegistryFile(entry.name, kind, knownWorkers, body);
1610
+ if (!classification) continue;
1611
+ links.push({
1612
+ kind,
1613
+ sourceRel: entry.name,
1614
+ targets: classification.targets,
1615
+ reason: classification.reason,
1616
+ });
1617
+ }
1618
+ }
1619
+ return links.filter((item) => item.targets.some((target) => !ruleRegistryLinkExists(item.kind, target, item.sourceRel)));
1620
+ }
1621
+
1622
+ function ruleRegistryLinkExists(kind, target, sourceRel) {
1623
+ const targetPath = path.join(HARNESS_DIR, kind, `${target}.md`);
1624
+ if (!fs.existsSync(targetPath)) return false;
1625
+ return fs.readFileSync(targetPath, 'utf8').includes(`](${sourceRel})`);
1626
+ }
1627
+
1628
+ function updateRuleRegistryLinks(links, { dryRun, backupDir }) {
1629
+ if (!links.length) return;
1630
+ for (const item of links) {
1631
+ const root = path.join(HARNESS_DIR, item.kind);
1632
+ const sourcePath = path.join(root, item.sourceRel);
1633
+ if (!fs.existsSync(sourcePath)) continue;
1634
+ for (const target of item.targets) {
1635
+ const targetPath = path.join(root, `${target}.md`);
1636
+ if (path.resolve(sourcePath) === path.resolve(targetPath) || ruleRegistryLinkExists(item.kind, target, item.sourceRel)) continue;
1637
+ log(` ${item.kind}/${target}.md: link ${item.sourceRel} (${item.reason})`);
1638
+ if (dryRun) continue;
1639
+ ensureDir(path.dirname(targetPath));
1640
+ const existing = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf8').trimEnd() : `# ${target.toUpperCase()} ${item.kind === 'gotchas' ? 'Gotchas' : 'Conventions'}`;
1641
+ if (fs.existsSync(targetPath)) {
1642
+ const backupTarget = path.join(backupDir, `${item.kind}-${target}.md`);
1643
+ fs.copyFileSync(targetPath, backupTarget);
1644
+ }
1645
+ const header = existing.includes(CXX_RULE_LINK_MARKER)
1646
+ ? ''
1647
+ : `\n\n## Related ${item.kind === 'gotchas' ? 'Gotcha' : 'Convention'} Links\n${CXX_RULE_LINK_MARKER}\n`;
1648
+ const line = `- [${item.sourceRel}](${item.sourceRel}) — ${item.reason}`;
1649
+ fs.writeFileSync(targetPath, `${existing}${header}${header ? '' : '\n'}${line}\n`);
1650
+ }
1651
+ }
1652
+ }
1653
+
1482
1654
  function showMigrationProposal(flags) {
1483
1655
  console.log('');
1484
1656
  console.log('╔══════════════════════════════════════════════════════════╗');
@@ -1544,6 +1716,12 @@ function showMigrationProposal(flags) {
1544
1716
  console.log(` ${file}: [${ids.join(', ')}]`);
1545
1717
  }
1546
1718
  }
1719
+ if (flags.ruleRegistryLinks && flags.ruleRegistryLinks.length) {
1720
+ console.log(' • conventions/gotchas: topic 파일을 CXX index 에 link 하여 lazy-load 가능하게 보강');
1721
+ for (const item of flags.ruleRegistryLinks) {
1722
+ console.log(` ${item.kind}/${item.sourceRel} → ${item.targets.map((t) => `${item.kind}/${t}.md`).join(', ')} (${item.reason})`);
1723
+ }
1724
+ }
1547
1725
  if (flags.bundleVersionStale) {
1548
1726
  const { current, installed } = flags.bundleVersionStale;
1549
1727
  console.log(` • bundle: ${installed ?? '(없음)'} → ${current} 스탬프 갱신 필요`);
@@ -1572,6 +1750,7 @@ function runMigrate(opts = {}) {
1572
1750
  const runtimeScriptsAlwaysRefresh = true;
1573
1751
  const gotchaMissingTotal = Object.values(flags.gotchaMissingEntries || {}).reduce((n, a) => n + a.length, 0);
1574
1752
  const conventionMissingTotal = Object.values(flags.conventionMissingEntries || {}).reduce((n, a) => n + a.length, 0);
1753
+ const ruleRegistryLinkTotal = (flags.ruleRegistryLinks || []).length;
1575
1754
  if (
1576
1755
  !flags.progressV3toV4 &&
1577
1756
  !flags.progressLegacyRouting &&
@@ -1588,6 +1767,7 @@ function runMigrate(opts = {}) {
1588
1767
  (!flags.memoryMissingSystemEntries || flags.memoryMissingSystemEntries.length === 0) &&
1589
1768
  gotchaMissingTotal === 0 &&
1590
1769
  conventionMissingTotal === 0 &&
1770
+ ruleRegistryLinkTotal === 0 &&
1591
1771
  !flags.bundleVersionStale &&
1592
1772
  !runtimeScriptsAlwaysRefresh
1593
1773
  ) {
@@ -1604,10 +1784,12 @@ function runMigrate(opts = {}) {
1604
1784
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
1605
1785
  const backupDir = path.join(HARNESS_DIR, 'archive', `migration-${ts}`);
1606
1786
  if (!dryRun) ensureDir(backupDir);
1787
+ ensureStructuredRuntimeFiles({ dryRun });
1607
1788
 
1608
1789
  // Runtime scripts are package-owned. Migrate must refresh them so existing
1609
1790
  // projects receive wake/meeting/OPS fixes without requiring a full init.
1610
1791
  refreshScriptsForMigrate({ dryRun, backupDir });
1792
+ updateRuleRegistryLinks(flags.ruleRegistryLinks || [], { dryRun, backupDir });
1611
1793
 
1612
1794
  // 1. progress.json
1613
1795
  const progressPath = path.join(HARNESS_DIR, 'progress.json');
@@ -15,4 +15,6 @@ Only company-level roles are provided by default:
15
15
  - `resource-manager.md`
16
16
  - `brick-office.md`
17
17
 
18
- Specialist worker conventions are not bundled here. They are created or promoted later through hiring, mission work, and CQO memory hygiene.
18
+ Topic-specific convention files may use descriptive names such as `i18n-locale.md`.
19
+
20
+ CXX files such as `cto.md` and `cqo.md` act as lazy-loading indexes. Add links there when a topic file applies to that CXX.
@@ -2,5 +2,6 @@
2
2
 
3
3
  - COO owns planning, research, service direction, reference models, and hypothesis validation.
4
4
  - COO records decisions in `.harness/documents/{mission_name}/coo.md`.
5
- - COO hires researchers, product planners, junior experiment developers, and documentation workers before delegating specialist work.
5
+ - COO hires researchers, product planners, MCP registry stewards/capability scanners, junior experiment developers, and documentation workers before delegating specialist work.
6
+ - COO planning must include an MCP Capability Inventory: available/applicable MCPs, required setup, read/write risk, recommended mission use, or explicit `None` when discovery is unavailable or no MCP applies.
6
7
  - COO reports mission fit, evidence, rejected options, and recommended next CXX to CEO.
package/gotchas/README.md CHANGED
@@ -15,4 +15,6 @@ Only company-level roles are provided by default:
15
15
  - `resource-manager.md`
16
16
  - `brick-office.md`
17
17
 
18
- Specialist worker gotchas are not bundled. CQO promotes recurring verified failures into this store as the company learns.
18
+ Topic-specific gotcha files may use descriptive names such as `i18n-locale-hotfix.md`.
19
+
20
+ CXX files such as `cto.md` and `cqo.md` act as lazy-loading indexes. Add links there when a topic file applies to that CXX.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walwal-harness/cli",
3
- "version": "7.1.25",
3
+ "version": "7.1.27",
4
4
  "description": "Company-style AI agent harness for Claude and Codex. Installs commands, CXX agents, skills, HR-Resource hiring pool, and project-local .harness runtime state.",
5
5
  "bin": {
6
6
  "walwal-harness": "bin/init.js"
@@ -70,6 +70,7 @@ TODAY="$(date +%Y-%m-%d)"
70
70
  register_one() {
71
71
  local target="$1" rule_id="$2" title="$3" wrong="$4" right="$5" why="$6" scope="$7" source="$8"
72
72
  local file="$GOTCHAS_DIR/${target}.md"
73
+ mkdir -p "$(dirname "$file")"
73
74
 
74
75
  # Ensure file exists with header
75
76
  if [ ! -f "$file" ]; then
@@ -184,6 +185,7 @@ register_convention_one() {
184
185
  local scope="$1" rule_id="$2" title="$3" rule="$4" why="$5" source="$6"
185
186
  mkdir -p "$CONVENTIONS_DIR"
186
187
  local file="$CONVENTIONS_DIR/${scope}.md"
188
+ mkdir -p "$(dirname "$file")"
187
189
 
188
190
  if [ ! -f "$file" ]; then
189
191
  cat > "$file" <<EOF
@@ -10,6 +10,8 @@ if [ -z "$CWD" ]; then CWD="$PWD"; fi
10
10
  # 조건 1: 하네스 초기화 확인
11
11
  if [ ! -f "$CWD/.harness/config.json" ]; then exit 0; fi
12
12
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
+ STRUCTURED_LIB="$SCRIPT_DIR/lib/harness-structured-log.sh"
14
+ [ -f "$STRUCTURED_LIB" ] && source "$STRUCTURED_LIB"
13
15
 
14
16
  # 조건 2: opt-out 플래그 확인
15
17
  AUTO_ROUTE="true"
@@ -71,7 +73,47 @@ if [ -n "$PROMPT" ] && [ -d "$CWD/.harness" ]; then
71
73
  COMMAND_TYPE="hot-fix"
72
74
  fi
73
75
  echo "$(date +"%Y-%m-%d %H:%M") | user-prompt | ${COMMAND_TYPE} | ${PROMPT_SHORT}" >> "$PROGRESS_LOG"
76
+ if declare -f harness_emit_event >/dev/null 2>&1; then
77
+ NOW_ISO_EVENT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
78
+ EVENT_JSON=$(jq -nc \
79
+ --arg ts "$NOW_ISO_EVENT" \
80
+ --arg command "$COMMAND_TYPE" \
81
+ --arg summary "$PROMPT_SHORT" \
82
+ '{ts:$ts,type:"user_prompt",command:$command,summary:$summary,source:"UserPromptSubmit"}')
83
+ harness_emit_event "$CWD" "$EVENT_JSON"
84
+ fi
74
85
  if [ "$COMMAND_TYPE" = "goal" ] || [ "$COMMAND_TYPE" = "submission" ] || [ "$COMMAND_TYPE" = "hot-fix" ]; then
86
+ if declare -f harness_todo_upsert >/dev/null 2>&1; then
87
+ NOW_ISO_TODO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
88
+ PRIORITY=50
89
+ KIND="$COMMAND_TYPE"
90
+ [ "$COMMAND_TYPE" = "hot-fix" ] && PRIORITY=100 && KIND="hotfix"
91
+ TODO_ID="ceo-${COMMAND_TYPE}-$(date -u +%Y%m%dT%H%M%SZ)"
92
+ TODO_JSON=$(jq -nc \
93
+ --arg id "$TODO_ID" \
94
+ --arg owner "ceo" \
95
+ --arg kind "$KIND" \
96
+ --arg title "$PROMPT_SHORT" \
97
+ --arg status "active" \
98
+ --arg now "$NOW_ISO_TODO" \
99
+ --argjson priority "$PRIORITY" \
100
+ '{
101
+ id:$id,
102
+ owner:$owner,
103
+ kind:$kind,
104
+ title:$title,
105
+ status:$status,
106
+ priority:$priority,
107
+ command:$kind,
108
+ mission_path:null,
109
+ required_artifacts:[],
110
+ created_at:$now,
111
+ updated_at:$now,
112
+ last_heartbeat_at:$now,
113
+ blocked_reason:null
114
+ }')
115
+ harness_todo_upsert "$CWD" "$TODO_JSON"
116
+ fi
75
117
  if [ -f "$CWD/.harness/progress.json" ] && command -v jq >/dev/null 2>&1; then
76
118
  TMP_PROGRESS="${CWD}/.harness/progress.json.tmp.$$"
77
119
  NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@@ -0,0 +1,59 @@
1
+ #!/bin/bash
2
+ # Structured runtime helpers for events.jsonl and todos/state.json.
3
+
4
+ harness_json_escape() {
5
+ jq -Rs . 2>/dev/null
6
+ }
7
+
8
+ harness_ensure_structured_runtime() {
9
+ local project_root="${1:-.}"
10
+ local harness_dir="$project_root/.harness"
11
+ [ -d "$harness_dir" ] || return 0
12
+ mkdir -p "$harness_dir/todos"
13
+ [ -f "$harness_dir/events.jsonl" ] || : > "$harness_dir/events.jsonl"
14
+ [ -f "$harness_dir/todos/events.jsonl" ] || : > "$harness_dir/todos/events.jsonl"
15
+ if [ ! -f "$harness_dir/todos/state.json" ]; then
16
+ jq -n --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{
17
+ version: 1,
18
+ updated_at: $now,
19
+ owners: {ceo: [], coo: [], cdo: [], cto: [], cqo: [], ops: []}
20
+ }' > "$harness_dir/todos/state.json"
21
+ fi
22
+ }
23
+
24
+ harness_emit_event() {
25
+ local project_root="$1"
26
+ local event_json="$2"
27
+ harness_ensure_structured_runtime "$project_root"
28
+ local path="$project_root/.harness/events.jsonl"
29
+ jq -c --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
30
+ . + {ts: (.ts // $now)}
31
+ ' <<<"$event_json" >> "$path" 2>/dev/null || true
32
+ }
33
+
34
+ harness_todo_upsert() {
35
+ local project_root="$1"
36
+ local todo_json="$2"
37
+ harness_ensure_structured_runtime "$project_root"
38
+ local state="$project_root/.harness/todos/state.json"
39
+ local events="$project_root/.harness/todos/events.jsonl"
40
+ local tmp="$state.tmp.$$"
41
+ local now
42
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
43
+
44
+ jq -c --arg now "$now" '. + {ts: (.ts // $now), type: (.type // "todo_upsert")}' <<<"$todo_json" >> "$events" 2>/dev/null || true
45
+ if jq --argjson todo "$todo_json" --arg now "$now" '
46
+ .version = (.version // 1) |
47
+ .updated_at = $now |
48
+ .owners = (.owners // {ceo: [], coo: [], cdo: [], cto: [], cqo: [], ops: []}) |
49
+ ($todo.owner // "ceo") as $owner |
50
+ .owners[$owner] = (.owners[$owner] // []) |
51
+ .owners[$owner] = ((.owners[$owner] | map(select(.id != $todo.id))) + [
52
+ ($todo + {updated_at: ($todo.updated_at // $now), created_at: ($todo.created_at // $now)})
53
+ ] | sort_by(-(.priority // 0), .created_at))
54
+ ' "$state" > "$tmp" 2>/dev/null; then
55
+ mv "$tmp" "$state"
56
+ else
57
+ rm -f "$tmp"
58
+ fi
59
+ }