create-principles-disciple 1.147.7 → 1.147.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/_release/product-identity.json +1 -1
- package/core/dist/host/host-installer.d.ts +15 -0
- package/core/dist/host/host-installer.d.ts.map +1 -1
- package/core/dist/host/host-installer.js.map +1 -1
- package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js +1 -1
- package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js.map +1 -1
- package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js +2 -2
- package/core/dist/runtime-v2/feature-flags/feature-flag-lifecycle.js.map +1 -1
- package/core/dist/runtime-v2/pain-recall-capability.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-recall-capability.js +53 -2
- package/core/dist/runtime-v2/pain-recall-capability.js.map +1 -1
- package/core/dist/runtime-v2/pain-recall-read-model.d.ts.map +1 -1
- package/core/dist/runtime-v2/pain-recall-read-model.js +60 -5
- package/core/dist/runtime-v2/pain-recall-read-model.js.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.d.ts.map +1 -1
- package/core/dist/runtime-v2/signal-collector/keyword-stage.js +35 -0
- package/core/dist/runtime-v2/signal-collector/keyword-stage.js.map +1 -1
- package/core/package.json +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -29
- package/dist/index.js.map +1 -1
- package/dist/install-summary.d.ts +38 -0
- package/dist/install-summary.d.ts.map +1 -0
- package/dist/install-summary.js +115 -0
- package/dist/install-summary.js.map +1 -0
- package/dist/installer.d.ts +36 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +120 -26
- package/dist/installer.js.map +1 -1
- package/dist/installers/workbuddy-host-installer.d.ts +51 -17
- package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/dist/installers/workbuddy-host-installer.js +510 -186
- package/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/dist/prompts.js +1 -1
- package/dist/prompts.js.map +1 -1
- package/host-runtime/dist/governance-signal-admission.d.ts +2 -2
- package/host-runtime/dist/governance-signal-admission.js +2 -6
- package/host-runtime/package.json +1 -1
- package/package.json +3 -3
- package/pd-cli/dist/commands/health-mcp.d.ts +19 -0
- package/pd-cli/dist/commands/health-mcp.d.ts.map +1 -1
- package/pd-cli/dist/commands/health-mcp.js +226 -0
- package/pd-cli/dist/commands/health-mcp.js.map +1 -1
- package/pd-cli/dist/commands/health.d.ts.map +1 -1
- package/pd-cli/dist/commands/health.js +18 -1
- package/pd-cli/dist/commands/health.js.map +1 -1
- package/pd-cli/dist/commands/mcp-logs-export.d.ts +7 -0
- package/pd-cli/dist/commands/mcp-logs-export.d.ts.map +1 -0
- package/pd-cli/dist/commands/mcp-logs-export.js +267 -0
- package/pd-cli/dist/commands/mcp-logs-export.js.map +1 -0
- package/pd-cli/dist/commands/workbuddy-worker.d.ts.map +1 -1
- package/pd-cli/dist/commands/workbuddy-worker.js +2 -1
- package/pd-cli/dist/commands/workbuddy-worker.js.map +1 -1
- package/pd-cli/dist/index.js +35 -0
- package/pd-cli/dist/index.js.map +1 -1
- package/pd-cli/dist/services/zip-writer.d.ts +6 -0
- package/pd-cli/dist/services/zip-writer.d.ts.map +1 -0
- package/pd-cli/dist/services/zip-writer.js +97 -0
- package/pd-cli/dist/services/zip-writer.js.map +1 -0
- package/pd-mcp-server/dist/index.d.ts +1 -0
- package/pd-mcp-server/dist/index.js +167 -10
- package/pd-mcp-server/dist/log-file.d.ts +26 -0
- package/pd-mcp-server/dist/log-file.js +312 -0
- package/pd-mcp-server/dist/logging.d.ts +2 -1
- package/pd-mcp-server/dist/logging.js +25 -14
- package/pd-mcp-server/dist/server.js +78 -24
- package/pd-mcp-server/dist/version.d.ts +1 -1
- package/pd-mcp-server/dist/version.js +1 -1
- package/pd-mcp-server/dist/workspace.d.ts +3 -0
- package/pd-mcp-server/dist/workspace.js +5 -0
- package/pd-mcp-server/package.json +1 -1
- package/plugin/dist/bundle.js +274 -273
- package/plugin/dist/openclaw.plugin.json +1 -1
- package/plugin/mcp/launcher.cjs +561 -0
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/installer.js +120 -26
- package/release-manager/dist/installers/workbuddy-host-installer.js +510 -186
- package/release-manager/package.json +1 -1
- package/workbuddy-adapter/dist/pd-hook.js +3 -2
- package/workbuddy-adapter/package.json +1 -1
- package/workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json +1 -1
- package/workbuddy-adapter/plugin/mcp/launcher.cjs +215 -1
- package/workbuddy-adapter/plugin/skills/pd-pain-signal/SKILL.md +4 -0
|
@@ -418,7 +418,7 @@ export function findWorkbuddyPluginPayload(pluginDir, runtimeAdapterDir) {
|
|
|
418
418
|
return { reason: `workbuddy_plugin_control_missing: ${root} must ship scripts/pd-control.cjs for the pd-disable skill.` };
|
|
419
419
|
}
|
|
420
420
|
const { version } = manifest;
|
|
421
|
-
return { payload: { pluginDir: root, version } };
|
|
421
|
+
return { payload: { pluginDir: root, version, defaultEnabled: manifest.defaultEnabled === true } };
|
|
422
422
|
}
|
|
423
423
|
return {
|
|
424
424
|
reason: `workbuddy_adapter_payload_missing: no WorkBuddy plugin skeleton found (looked for workbuddy-adapter/plugin/.codebuddy-plugin/plugin.json under the installer payload and ${runtimeAdapterDir}). The @principles/workbuddy-adapter component is not part of this installer build.`,
|
|
@@ -515,10 +515,7 @@ function removeWorkbuddyFromInstallManifestHosts(manifestPath) {
|
|
|
515
515
|
}
|
|
516
516
|
// ─── Feature flag override (config.yaml line-targeted edit) ─────────────────
|
|
517
517
|
const FEATURES_LINE = /^features:\s*(?:#.*)?$/;
|
|
518
|
-
const FEATURES_EMPTY_MAPPING_LINE = /^features:\s*\{\}\s*(?:#.*)?$/;
|
|
519
|
-
const WORKBUDDY_KEY_LINE = /^ {2}host\.workbuddy:\s*(?:#.*)?$/;
|
|
520
|
-
const WORKBUDDY_ENABLED_LINE = /^ {4}enabled:\s*(?:true|false)\s*(?:#.*)?$/;
|
|
521
|
-
const WORKBUDDY_CATEGORY_LINE = /^ {4}category:(?:\s.*)?(?:#.*)?$/;
|
|
518
|
+
const FEATURES_EMPTY_MAPPING_LINE = /^features:\s*\{\s*\}\s*(?:#.*)?$/;
|
|
522
519
|
/**
|
|
523
520
|
* Registry-required category for the host.workbuddy flag entry. SSoT:
|
|
524
521
|
* packages/principles-core/src/runtime-v2/feature-flags/feature-flag-contract.ts
|
|
@@ -533,8 +530,318 @@ function stripYamlComment(line) {
|
|
|
533
530
|
const hash = line.indexOf('#');
|
|
534
531
|
return (hash === -1 ? line : line.slice(0, hash)).trim();
|
|
535
532
|
}
|
|
536
|
-
function
|
|
537
|
-
return
|
|
533
|
+
function leadingSpaces(line) {
|
|
534
|
+
return /^ +/.exec(line)?.[0].length ?? 0;
|
|
535
|
+
}
|
|
536
|
+
function escapeRegExp(value) {
|
|
537
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
538
|
+
}
|
|
539
|
+
const FLAG_EDIT_MANUAL_GUIDANCE = 'Edit the governance config.yaml by hand: keep the block-style mapping and change only features.<key>.enabled (or add the key under the top-level features: mapping). The installer refuses whole-file rewrites so Owner comments and unknown fields survive.';
|
|
540
|
+
function failLineEdit(configPath, code, detail) {
|
|
541
|
+
return {
|
|
542
|
+
ok: false,
|
|
543
|
+
reason: `${code}: ${detail} (${configPath}). No write was performed.`,
|
|
544
|
+
nextAction: `${FLAG_EDIT_MANUAL_GUIDANCE} Affected file: ${configPath}.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function inspectFeaturesHeader(lines) {
|
|
548
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
549
|
+
const line = lines[index];
|
|
550
|
+
if (typeof line !== 'string' || !/^features:/.test(line))
|
|
551
|
+
continue;
|
|
552
|
+
if (FEATURES_LINE.test(line))
|
|
553
|
+
return { index, style: 'block' };
|
|
554
|
+
if (FEATURES_EMPTY_MAPPING_LINE.test(line))
|
|
555
|
+
return { index, style: 'empty' };
|
|
556
|
+
return { index, style: 'flow' };
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
function findUnsafeYamlConstruct(lines, start) {
|
|
561
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
562
|
+
const line = lines[index];
|
|
563
|
+
if (typeof line !== 'string')
|
|
564
|
+
break;
|
|
565
|
+
const body = stripYamlComment(line);
|
|
566
|
+
if (index > start && body.trim() === '')
|
|
567
|
+
continue;
|
|
568
|
+
if (index > start && !/^\s/.test(line))
|
|
569
|
+
break;
|
|
570
|
+
if (/^\s*<<:/.test(body))
|
|
571
|
+
return { line: index, kind: 'YAML merge key (<<:)' };
|
|
572
|
+
if (/(?:^|\s)&[A-Za-z0-9_]/.test(body))
|
|
573
|
+
return { line: index, kind: 'YAML anchor (&)' };
|
|
574
|
+
if (/(?:^|\s)\*[A-Za-z0-9_]/.test(body))
|
|
575
|
+
return { line: index, kind: 'YAML alias (*)' };
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
function featuresBlockEnd(lines, start) {
|
|
580
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
581
|
+
const line = lines[index];
|
|
582
|
+
if (typeof line !== 'string')
|
|
583
|
+
return index;
|
|
584
|
+
const body = line.trim();
|
|
585
|
+
if (body === '' || body.startsWith('#'))
|
|
586
|
+
continue;
|
|
587
|
+
if (!/^\s/.test(line))
|
|
588
|
+
return index;
|
|
589
|
+
}
|
|
590
|
+
return lines.length;
|
|
591
|
+
}
|
|
592
|
+
function findKeyLineIndexes(lines, range, flagId) {
|
|
593
|
+
const indexes = [];
|
|
594
|
+
for (let index = range.start + 1; index < range.end; index += 1) {
|
|
595
|
+
const line = lines[index];
|
|
596
|
+
if (typeof line !== 'string')
|
|
597
|
+
break;
|
|
598
|
+
if (stripYamlComment(line).trim() === `${flagId}:`)
|
|
599
|
+
indexes.push(index);
|
|
600
|
+
}
|
|
601
|
+
return indexes;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Detect a flag entry written inline (`flagId: { enabled: true }` or
|
|
605
|
+
* `flagId: [ ... ]`) inside the features block. Such an entry has no
|
|
606
|
+
* independent `enabled:` line to rewrite, so it is refused explicitly rather
|
|
607
|
+
* than reported as an unparsable rewrite after a duplicate key is inserted.
|
|
608
|
+
*/
|
|
609
|
+
function findInlineFlagEntry(lines, range, flagId) {
|
|
610
|
+
const pattern = new RegExp(`^${escapeRegExp(flagId)}:\\s*[\\[{]`);
|
|
611
|
+
for (let index = range.start + 1; index < range.end; index += 1) {
|
|
612
|
+
const line = lines[index];
|
|
613
|
+
if (typeof line !== 'string')
|
|
614
|
+
break;
|
|
615
|
+
if (pattern.test(stripYamlComment(line).trim()))
|
|
616
|
+
return index;
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
function flagBlockEnd(lines, keyIndex, keyIndent) {
|
|
621
|
+
for (let index = keyIndex + 1; index < lines.length; index += 1) {
|
|
622
|
+
const line = lines[index];
|
|
623
|
+
if (typeof line !== 'string')
|
|
624
|
+
return index;
|
|
625
|
+
const body = line.trim();
|
|
626
|
+
if (body === '' || body.startsWith('#'))
|
|
627
|
+
continue;
|
|
628
|
+
if (leadingSpaces(line) <= keyIndent)
|
|
629
|
+
return index;
|
|
630
|
+
}
|
|
631
|
+
return lines.length;
|
|
632
|
+
}
|
|
633
|
+
function childIndentOf(lines, keyIndex, options) {
|
|
634
|
+
for (let index = keyIndex + 1; index < options.end; index += 1) {
|
|
635
|
+
const line = lines[index];
|
|
636
|
+
if (typeof line !== 'string')
|
|
637
|
+
continue;
|
|
638
|
+
const body = line.trim();
|
|
639
|
+
if (body === '' || body.startsWith('#'))
|
|
640
|
+
continue;
|
|
641
|
+
const indent = leadingSpaces(line);
|
|
642
|
+
if (indent > options.keyIndent)
|
|
643
|
+
return indent;
|
|
644
|
+
}
|
|
645
|
+
return options.keyIndent + 2;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Infer the child indentation used by an existing features block, so a
|
|
649
|
+
* missing flag is inserted in the file's own style (2, 4, 6 spaces …) instead
|
|
650
|
+
* of a guessed `key + 2`. Returns null when the block has no nested content to
|
|
651
|
+
* learn from.
|
|
652
|
+
*/
|
|
653
|
+
function inferChildIndent(lines, range, keyIndent) {
|
|
654
|
+
let parentIndent = null;
|
|
655
|
+
for (let index = range.start; index < range.end; index += 1) {
|
|
656
|
+
const line = lines[index];
|
|
657
|
+
if (typeof line !== 'string')
|
|
658
|
+
continue;
|
|
659
|
+
const body = line.trim();
|
|
660
|
+
if (body === '' || body.startsWith('#'))
|
|
661
|
+
continue;
|
|
662
|
+
const indent = leadingSpaces(line);
|
|
663
|
+
if (parentIndent !== null && indent > parentIndent)
|
|
664
|
+
return indent;
|
|
665
|
+
parentIndent = indent === keyIndent ? indent : null;
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
function findFieldIndexes(lines, range, options) {
|
|
670
|
+
const pattern = new RegExp(`^ {${options.indent}}${escapeRegExp(options.field)}:(\\s|$)`);
|
|
671
|
+
const indexes = [];
|
|
672
|
+
for (let index = range.start; index < range.end; index += 1) {
|
|
673
|
+
const line = lines[index];
|
|
674
|
+
if (typeof line !== 'string')
|
|
675
|
+
continue;
|
|
676
|
+
if (pattern.test(line))
|
|
677
|
+
indexes.push(index);
|
|
678
|
+
}
|
|
679
|
+
return indexes;
|
|
680
|
+
}
|
|
681
|
+
function rewriteScalarLine(line, field, value) {
|
|
682
|
+
const match = new RegExp(`^(\\s*)${escapeRegExp(field)}:(\\s*)(.*)$`).exec(line);
|
|
683
|
+
if (!match)
|
|
684
|
+
return null;
|
|
685
|
+
const remainder = match[3] ?? '';
|
|
686
|
+
const hash = remainder.indexOf('#');
|
|
687
|
+
const comment = hash === -1 ? '' : remainder.slice(hash);
|
|
688
|
+
const valuePart = hash === -1 ? remainder : remainder.slice(0, hash);
|
|
689
|
+
const trailing = /\s*$/.exec(valuePart)?.[0] ?? '';
|
|
690
|
+
const rawValue = valuePart.trim();
|
|
691
|
+
if (rawValue === '')
|
|
692
|
+
return null;
|
|
693
|
+
if (/^(?:~|null|Null|NULL)$/.test(rawValue))
|
|
694
|
+
return null;
|
|
695
|
+
const quoted = /^(['"])([^'"]*)(['"])$/.exec(rawValue);
|
|
696
|
+
const quote = quoted ? quoted[1] : '';
|
|
697
|
+
return `${match[1]}${field}:${match[2]}${quote}${value}${quote}${trailing}${comment}`;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Locate `features.<flagId>` in block-style YAML and rewrite ONLY its
|
|
701
|
+
* `enabled` line (plus, when missing, its registry `category` line). Every
|
|
702
|
+
* byte outside the flag block is verified unchanged before writing; anything
|
|
703
|
+
* the editor cannot locate safely fails closed instead of falling back to a
|
|
704
|
+
* whole-file `yaml.dump` (which would drop Owner comments).
|
|
705
|
+
*
|
|
706
|
+
* KNOWN LIMITATIONS (all fail closed, never rewritten; the first two are
|
|
707
|
+
* rejected by the YAML parse that runs before any edit):
|
|
708
|
+
* - tab-based indentation (invalid YAML);
|
|
709
|
+
* - any config that does not parse;
|
|
710
|
+
* - `features:` or the flag entry written in flow style (`{ ... }` / `[ ... ]`);
|
|
711
|
+
* - YAML anchors, aliases or merge keys (`&`, `*`, `<<:`) inside features;
|
|
712
|
+
* - a flag appearing twice, or the same field twice inside one flag block;
|
|
713
|
+
* - a field that is not a scalar (`enabled`/`category` written as a nested map).
|
|
714
|
+
*/
|
|
715
|
+
function applyFeatureFlagLineEdit(raw, request) {
|
|
716
|
+
const { flagId, enabled, category, configPath } = request;
|
|
717
|
+
// Preserve the file's own line ending: inserting "\n" into a CRLF config
|
|
718
|
+
// would change every following byte's neighbourhood (mixed EOL).
|
|
719
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
720
|
+
const lines = raw.split(eol);
|
|
721
|
+
const header = inspectFeaturesHeader(lines);
|
|
722
|
+
if (header?.style === 'flow') {
|
|
723
|
+
return failLineEdit(configPath, 'config_features_flow_style', 'the top-level features: mapping is written inline (flow style)');
|
|
724
|
+
}
|
|
725
|
+
if (header) {
|
|
726
|
+
const unsafe = findUnsafeYamlConstruct(lines, header.index);
|
|
727
|
+
if (unsafe !== null) {
|
|
728
|
+
return failLineEdit(configPath, 'config_yaml_anchor_or_merge_key', `${unsafe.kind} at line ${unsafe.line + 1}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const featuresStart = header?.index ?? lines.length;
|
|
732
|
+
const featuresEnd = header ? featuresBlockEnd(lines, header.index) : lines.length;
|
|
733
|
+
const featuresRange = { start: featuresStart, end: featuresEnd };
|
|
734
|
+
if (header) {
|
|
735
|
+
const inline = findInlineFlagEntry(lines, featuresRange, flagId);
|
|
736
|
+
if (inline !== null) {
|
|
737
|
+
return failLineEdit(configPath, 'config_flag_flow_style', `features.${flagId} is written inline at line ${inline + 1}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const keyIndexes = header ? findKeyLineIndexes(lines, featuresRange, flagId) : [];
|
|
741
|
+
if (keyIndexes.length > 1) {
|
|
742
|
+
return failLineEdit(configPath, 'config_flag_block_ambiguous', `features.${flagId} appears ${keyIndexes.length} times`);
|
|
743
|
+
}
|
|
744
|
+
let working;
|
|
745
|
+
let allowedStart;
|
|
746
|
+
let allowedEnd;
|
|
747
|
+
if (keyIndexes.length === 1) {
|
|
748
|
+
const [keyIndex] = keyIndexes;
|
|
749
|
+
const keyLine = keyIndex === undefined ? undefined : lines[keyIndex];
|
|
750
|
+
if (keyIndex === undefined || keyLine === undefined) {
|
|
751
|
+
return failLineEdit(configPath, 'config_flag_block_ambiguous', `features.${flagId} could not be located`);
|
|
752
|
+
}
|
|
753
|
+
const keyIndent = leadingSpaces(keyLine);
|
|
754
|
+
const blockEnd = flagBlockEnd(lines, keyIndex, keyIndent);
|
|
755
|
+
const childIndent = childIndentOf(lines, keyIndex, { end: blockEnd, keyIndent });
|
|
756
|
+
working = [...lines];
|
|
757
|
+
const enabledIndexes = findFieldIndexes(working, { start: keyIndex + 1, end: blockEnd }, { field: 'enabled', indent: childIndent });
|
|
758
|
+
if (enabledIndexes.length > 1) {
|
|
759
|
+
return failLineEdit(configPath, 'config_flag_field_duplicate', `features.${flagId}.enabled appears ${enabledIndexes.length} times`);
|
|
760
|
+
}
|
|
761
|
+
const [existingEnabled] = enabledIndexes;
|
|
762
|
+
let enabledIndex;
|
|
763
|
+
if (existingEnabled !== undefined) {
|
|
764
|
+
const enabledLine = working[existingEnabled];
|
|
765
|
+
const rewritten = typeof enabledLine === 'string' ? rewriteScalarLine(enabledLine, 'enabled', String(enabled)) : null;
|
|
766
|
+
if (rewritten === null) {
|
|
767
|
+
return failLineEdit(configPath, 'config_flag_field_unsupported', `features.${flagId}.enabled is not a scalar boolean`);
|
|
768
|
+
}
|
|
769
|
+
working[existingEnabled] = rewritten;
|
|
770
|
+
enabledIndex = existingEnabled;
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
enabledIndex = keyIndex + 1;
|
|
774
|
+
working.splice(enabledIndex, 0, `${' '.repeat(childIndent)}enabled: ${enabled}`);
|
|
775
|
+
}
|
|
776
|
+
const blockEndAfterInsert = flagBlockEnd(working, keyIndex, keyIndent);
|
|
777
|
+
const categoryIndexes = findFieldIndexes(working, { start: keyIndex + 1, end: blockEndAfterInsert }, { field: 'category', indent: childIndent });
|
|
778
|
+
if (categoryIndexes.length > 1) {
|
|
779
|
+
return failLineEdit(configPath, 'config_flag_field_duplicate', `features.${flagId}.category appears ${categoryIndexes.length} times`);
|
|
780
|
+
}
|
|
781
|
+
const [existingCategory] = categoryIndexes;
|
|
782
|
+
if (existingCategory !== undefined) {
|
|
783
|
+
const categoryLine = working[existingCategory];
|
|
784
|
+
const rewritten = typeof categoryLine === 'string' ? rewriteScalarLine(categoryLine, 'category', category) : null;
|
|
785
|
+
if (rewritten === null) {
|
|
786
|
+
return failLineEdit(configPath, 'config_flag_field_unsupported', `features.${flagId}.category is not a scalar string`);
|
|
787
|
+
}
|
|
788
|
+
working[existingCategory] = rewritten;
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
working.splice(enabledIndex + 1, 0, `${' '.repeat(childIndent)}category: ${category}`);
|
|
792
|
+
}
|
|
793
|
+
allowedStart = keyIndex;
|
|
794
|
+
allowedEnd = blockEnd;
|
|
795
|
+
}
|
|
796
|
+
else if (header === null) {
|
|
797
|
+
working = [
|
|
798
|
+
...lines,
|
|
799
|
+
'features:',
|
|
800
|
+
` ${flagId}:`,
|
|
801
|
+
` enabled: ${enabled}`,
|
|
802
|
+
` category: ${category}`,
|
|
803
|
+
];
|
|
804
|
+
allowedStart = lines.length;
|
|
805
|
+
allowedEnd = lines.length;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
working = [...lines];
|
|
809
|
+
let keyIndent = 2;
|
|
810
|
+
const firstChild = working.slice(header.index + 1, featuresEnd).find((line) => {
|
|
811
|
+
if (typeof line !== 'string')
|
|
812
|
+
return false;
|
|
813
|
+
const body = line.trim();
|
|
814
|
+
return body !== '' && !body.startsWith('#');
|
|
815
|
+
});
|
|
816
|
+
if (typeof firstChild === 'string')
|
|
817
|
+
keyIndent = leadingSpaces(firstChild);
|
|
818
|
+
const childIndent = inferChildIndent(working, { start: header.index + 1, end: featuresEnd }, keyIndent) ?? keyIndent + 2;
|
|
819
|
+
const insertAt = header.index + 1;
|
|
820
|
+
if (header.style === 'empty')
|
|
821
|
+
working[header.index] = 'features:';
|
|
822
|
+
working.splice(insertAt, 0, `${' '.repeat(keyIndent)}${flagId}:`, `${' '.repeat(childIndent)}enabled: ${enabled}`, `${' '.repeat(childIndent)}category: ${category}`);
|
|
823
|
+
allowedStart = header.style === 'empty' ? header.index : insertAt;
|
|
824
|
+
allowedEnd = header.style === 'empty' ? header.index + 1 : insertAt;
|
|
825
|
+
}
|
|
826
|
+
const next = working.join(eol);
|
|
827
|
+
const prefix = lines.slice(0, allowedStart).join(eol);
|
|
828
|
+
const suffix = lines.slice(allowedEnd).join(eol);
|
|
829
|
+
if (!next.startsWith(prefix) || !next.endsWith(suffix)) {
|
|
830
|
+
return failLineEdit(configPath, 'config_edit_out_of_scope', `bytes outside features.${flagId} would change`);
|
|
831
|
+
}
|
|
832
|
+
let parsed;
|
|
833
|
+
try {
|
|
834
|
+
parsed = yaml.load(next);
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return failLineEdit(configPath, 'config_rewrite_invalid', `the edited YAML no longer parses`);
|
|
838
|
+
}
|
|
839
|
+
const features = isRecord(parsed) ? parsed.features : undefined;
|
|
840
|
+
const entry = isRecord(features) ? features[flagId] : undefined;
|
|
841
|
+
if (!isRecord(entry) || entry.enabled !== enabled || entry.category !== category) {
|
|
842
|
+
return failLineEdit(configPath, 'config_rewrite_verification_failed', `features.${flagId} did not round-trip to enabled=${String(enabled)}, category=${category}`);
|
|
843
|
+
}
|
|
844
|
+
return { ok: true, next };
|
|
538
845
|
}
|
|
539
846
|
/**
|
|
540
847
|
* Enable/disable `features.host.workbuddy.enabled` in the GOVERNANCE
|
|
@@ -551,75 +858,125 @@ function isEmptyFeaturesMapping(line) {
|
|
|
551
858
|
* skipped with host.workbuddy_disabled.
|
|
552
859
|
* Both entries are validated and atomically committed in one file write;
|
|
553
860
|
* an already-correct config is left byte-identical (no rewrite).
|
|
861
|
+
*
|
|
862
|
+
* 2026-11-05 (Owner decision): `workbuddy_live_keyword_pain` is written
|
|
863
|
+
* `enabled: false` unconditionally. WorkBuddy has no persisted observation
|
|
864
|
+
* writer, so the automatic keyword path can only end as `observation_only`
|
|
865
|
+
* (an abandoned marker, never a Pain row). Leaving the flag ON made install
|
|
866
|
+
* output claim an automatic capability that does not exist. The flag id stays
|
|
867
|
+
* (it is part of the feature-flag contract and may exist in old configs), but
|
|
868
|
+
* the installer never turns it on.
|
|
554
869
|
*/
|
|
555
870
|
export function ensureWorkbuddyGovernanceEnabled(governanceDir) {
|
|
556
871
|
const configPath = path.join(governanceDir, '.pd', 'config.yaml');
|
|
872
|
+
let rawText;
|
|
873
|
+
try {
|
|
874
|
+
rawText = readFileSync(configPath, 'utf-8');
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
reason: 'fresh_workbuddy_config_unreadable',
|
|
880
|
+
nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
|
|
881
|
+
hostExplicitOff: false,
|
|
882
|
+
keywordExplicitOff: false,
|
|
883
|
+
keywordPainEnabled: false,
|
|
884
|
+
changed: false,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
557
887
|
let raw;
|
|
558
888
|
try {
|
|
559
|
-
raw = yaml.load(
|
|
889
|
+
raw = yaml.load(rawText);
|
|
560
890
|
}
|
|
561
891
|
catch {
|
|
562
|
-
return {
|
|
892
|
+
return {
|
|
893
|
+
ok: false,
|
|
894
|
+
reason: 'fresh_workbuddy_config_unreadable',
|
|
895
|
+
nextAction: `Fix the YAML by hand (the installer never rewrites the whole file), then retry. Affected file: ${configPath}.`,
|
|
896
|
+
hostExplicitOff: false,
|
|
897
|
+
keywordExplicitOff: false,
|
|
898
|
+
keywordPainEnabled: false,
|
|
899
|
+
changed: false,
|
|
900
|
+
};
|
|
563
901
|
}
|
|
564
902
|
if (!isRecord(raw) || !isRecord(raw.features)) {
|
|
565
|
-
return { ok: false, reason: 'fresh_workbuddy_features_invalid', nextAction: 'Governance config needs a features mapping.', hostExplicitOff: false, keywordExplicitOff: false, changed: false };
|
|
903
|
+
return { ok: false, reason: 'fresh_workbuddy_features_invalid', nextAction: 'Governance config needs a features mapping.', hostExplicitOff: false, keywordExplicitOff: false, keywordPainEnabled: false, changed: false };
|
|
566
904
|
}
|
|
567
905
|
const oldHost = raw.features['host.workbuddy'];
|
|
568
906
|
const oldKeyword = raw.features.workbuddy_live_keyword_pain;
|
|
569
907
|
const hostExplicitOff = isRecord(oldHost) && oldHost.enabled === false;
|
|
570
908
|
const keywordExplicitOff = isRecord(oldKeyword) && oldKeyword.enabled === false;
|
|
571
|
-
const features = {
|
|
572
|
-
...raw.features,
|
|
573
|
-
'host.workbuddy': { ...(isRecord(oldHost) ? oldHost : {}), enabled: !hostExplicitOff, category: WORKBUDDY_FLAG_CATEGORY },
|
|
574
|
-
workbuddy_live_keyword_pain: { ...(isRecord(oldKeyword) ? oldKeyword : {}), enabled: !keywordExplicitOff, category: WORKBUDDY_FLAG_CATEGORY },
|
|
575
|
-
};
|
|
576
|
-
const next = yaml.dump({ ...raw, features }, { lineWidth: -1, noRefs: true });
|
|
577
909
|
const alreadyCorrect = isRecord(oldHost) && oldHost.enabled === !hostExplicitOff && oldHost.category === WORKBUDDY_FLAG_CATEGORY
|
|
578
|
-
&& isRecord(oldKeyword) && oldKeyword.enabled ===
|
|
910
|
+
&& isRecord(oldKeyword) && oldKeyword.enabled === false && oldKeyword.category === WORKBUDDY_FLAG_CATEGORY;
|
|
579
911
|
if (alreadyCorrect) {
|
|
580
|
-
return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: false };
|
|
912
|
+
return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
|
|
581
913
|
}
|
|
914
|
+
// Line-targeted edits: only the two flag blocks change; every comment,
|
|
915
|
+
// unknown feature and unrelated byte outside them is verified unchanged.
|
|
916
|
+
const edits = [
|
|
917
|
+
{ flagId: 'host.workbuddy', enabled: !hostExplicitOff },
|
|
918
|
+
{ flagId: 'workbuddy_live_keyword_pain', enabled: false },
|
|
919
|
+
];
|
|
920
|
+
let next = rawText;
|
|
921
|
+
for (const edit of edits) {
|
|
922
|
+
const outcome = applyFeatureFlagLineEdit(next, {
|
|
923
|
+
flagId: edit.flagId,
|
|
924
|
+
enabled: edit.enabled,
|
|
925
|
+
category: WORKBUDDY_FLAG_CATEGORY,
|
|
926
|
+
configPath,
|
|
927
|
+
});
|
|
928
|
+
if (!outcome.ok) {
|
|
929
|
+
return { ok: false, reason: outcome.reason, nextAction: outcome.nextAction, hostExplicitOff, keywordExplicitOff, keywordPainEnabled: false, changed: false };
|
|
930
|
+
}
|
|
931
|
+
({ next } = outcome);
|
|
932
|
+
}
|
|
933
|
+
// The summary must report the gate that the config ACTUALLY holds, not the
|
|
934
|
+
// value we intended to write. `applyFeatureFlagLineEdit` has already parsed
|
|
935
|
+
// `next` successfully, so this read only feeds the summary.
|
|
936
|
+
const finalParsed = yaml.load(next);
|
|
937
|
+
const finalFeatures = isRecord(finalParsed) ? finalParsed.features : undefined;
|
|
938
|
+
const finalKeyword = isRecord(finalFeatures) ? finalFeatures.workbuddy_live_keyword_pain : undefined;
|
|
939
|
+
const keywordPainEnabled = isRecord(finalKeyword) && finalKeyword.enabled === true;
|
|
582
940
|
try {
|
|
583
|
-
const verify = yaml.load(next);
|
|
584
|
-
if (!isRecord(verify) || !isRecord(verify.features) ||
|
|
585
|
-
!isRecord(verify.features['host.workbuddy']) || verify.features['host.workbuddy'].enabled !== !hostExplicitOff ||
|
|
586
|
-
!isRecord(verify.features.workbuddy_live_keyword_pain) || verify.features.workbuddy_live_keyword_pain.enabled !== !keywordExplicitOff) {
|
|
587
|
-
return { ok: false, reason: 'fresh_workbuddy_flags_verification_failed', nextAction: 'No changes written.', hostExplicitOff, keywordExplicitOff, changed: false };
|
|
588
|
-
}
|
|
589
941
|
atomicReplaceTextFile(configPath, next);
|
|
590
942
|
}
|
|
591
943
|
catch {
|
|
592
|
-
return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, changed: false };
|
|
944
|
+
return { ok: false, reason: 'fresh_workbuddy_flag_write_failed', nextAction: 'Repair config storage and retry.', hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: false };
|
|
593
945
|
}
|
|
594
|
-
return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, changed: true };
|
|
946
|
+
return { ok: true, configPath, enabled: !hostExplicitOff, hostExplicitOff, keywordExplicitOff, keywordPainEnabled, changed: true };
|
|
595
947
|
}
|
|
596
948
|
/**
|
|
597
|
-
* PRI-1064 review: the install summary must state
|
|
598
|
-
*
|
|
599
|
-
* The first version derived the whole sentence from `hostExplicitOff` alone,
|
|
600
|
-
* so a config with `workbuddy_live_keyword_pain.enabled: false` was reported as
|
|
601
|
-
* "governance and four-term keyword Pain detection are ON" — a false summary
|
|
602
|
-
* of exactly the kind this change set out to remove. Each gate is now stated
|
|
603
|
-
* on its own, and an OFF state always names the flag the Owner wrote.
|
|
949
|
+
* PRI-1064 review: the install summary must state the governance gate
|
|
950
|
+
* truthfully.
|
|
604
951
|
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
952
|
+
* 2026-11-05 (Owner decision): WorkBuddy has no persisted observation writer,
|
|
953
|
+
* so automatic Pain detection is UNSUPPORTED there — every keyword candidate
|
|
954
|
+
* ends as `observation_only` and is never persisted. The summary therefore
|
|
955
|
+
* NEVER reports keyword Pain as ON; it states the actual intake path (the
|
|
956
|
+
* explicit `PD记录痛苦:` prefix) instead. Reporting the keyword gate as ON was
|
|
957
|
+
* a false capability claim of exactly the kind this change set out to remove.
|
|
608
958
|
*/
|
|
609
959
|
export function describeGovernanceDefaults(state, governanceDir) {
|
|
610
960
|
if (state === null) {
|
|
611
961
|
return ` Governance flag state is UNKNOWN — no governance config exists at ${governanceDir} (run "pd runtime init --workspace ${governanceDir}", then re-run the installer).`;
|
|
612
962
|
}
|
|
963
|
+
const manualIntake = 'Automatic Pain detection is not available in WorkBuddy; record pain with the explicit prefix "PD记录痛苦:".';
|
|
613
964
|
if (state.hostExplicitOff) {
|
|
614
|
-
return
|
|
965
|
+
return ` WorkBuddy governance is OFF — your explicit opt-out (features.host.workbuddy.enabled=false) was preserved; four-term keyword Pain detection is OFF${state.keywordExplicitOff ? ' (the OFF setting was preserved)' : ' (automatic detection is unavailable in WorkBuddy, so it is kept OFF)'}. ${manualIntake}`;
|
|
615
966
|
}
|
|
616
|
-
const hostState = ` WorkBuddy governance is ON${state.changed ? ' (enabled by this install)' : ' (already enabled)'}`;
|
|
967
|
+
const hostState = ` WorkBuddy governance is ON${state.changed ? ' (enabled by this install)' : ' (already enabled)'} — this flag only acts through the plugin/hook channel, so it has no effect until the plugin is enabled in WorkBuddy`;
|
|
617
968
|
const keywordState = state.keywordExplicitOff
|
|
618
|
-
? 'four-term keyword Pain detection is OFF —
|
|
619
|
-
: 'four-term keyword Pain detection is
|
|
620
|
-
return `${hostState}; ${keywordState}
|
|
969
|
+
? 'four-term keyword Pain detection is OFF — the OFF setting was preserved (an explicit Owner opt-out is never overridden)'
|
|
970
|
+
: 'four-term keyword Pain detection is OFF — automatic detection is unavailable in WorkBuddy, so it is kept OFF';
|
|
971
|
+
return `${hostState}; ${keywordState}. ${manualIntake}`;
|
|
621
972
|
}
|
|
622
|
-
|
|
973
|
+
/**
|
|
974
|
+
* Generic line-targeted flag editor (flag id + registry category are
|
|
975
|
+
* parameters). Writes only the flag's `enabled`/`category` lines, keeps every
|
|
976
|
+
* other byte intact, and fails closed when the block cannot be located safely.
|
|
977
|
+
*/
|
|
978
|
+
export function setWorkbuddyFeatureFlag(governanceDir, flag) {
|
|
979
|
+
const { flagId, enabled, category } = flag;
|
|
623
980
|
const configDir = path.join(governanceDir, '.pd');
|
|
624
981
|
const configPath = path.join(configDir, 'config.yaml');
|
|
625
982
|
let raw;
|
|
@@ -638,125 +995,28 @@ export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
|
|
|
638
995
|
return {
|
|
639
996
|
ok: false,
|
|
640
997
|
reason: `config_unparsable: ${configPath} is not valid YAML (${error instanceof Error ? error.message : String(error)}). No write was performed.`,
|
|
641
|
-
nextAction: 'Fix or delete the malformed config.yaml, then retry
|
|
998
|
+
nextAction: 'Fix or delete the malformed config.yaml by hand, then retry.',
|
|
642
999
|
};
|
|
643
1000
|
}
|
|
644
1001
|
}
|
|
645
1002
|
else {
|
|
646
1003
|
raw = generateConfigYamlContent();
|
|
647
1004
|
}
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const line = lines[i];
|
|
652
|
-
if (typeof line === 'string' && WORKBUDDY_KEY_LINE.test(line)) {
|
|
653
|
-
keyIndex = i;
|
|
654
|
-
break;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (keyIndex >= 0) {
|
|
658
|
-
// Remediation (review 2026-10-13 §3 P2 + round-3 P2): locate the existing
|
|
659
|
-
// `enabled` and `category` fields WITHIN the flag's mapping block. The
|
|
660
|
-
// block boundary is INDENTATION, not field identity: it spans every
|
|
661
|
-
// non-blank line indented ≥ 4 spaces after the 2-space `host.workbuddy:`
|
|
662
|
-
// key, plus the comments/blank lines interleaved with them, and ends at
|
|
663
|
-
// the first structural line indented < 4 spaces (dedent / next key) or
|
|
664
|
-
// EOF. Legal entry fields other than the two we manage — e.g. `source:`
|
|
665
|
-
// (PRI-637 provenance, FeatureFlagEntry source?: 'owner' | 'system') —
|
|
666
|
-
// live INSIDE the block and must NEVER end the scan: the previous
|
|
667
|
-
// first-unrecognized-line break mistook `source: owner` for the block end
|
|
668
|
-
// and spliced duplicate keys above it, so the rewrite verification threw
|
|
669
|
-
// (config_rewrite_invalid) and enable/disable was refused with the stale
|
|
670
|
-
// value preserved.
|
|
671
|
-
let enabledIndex = -1;
|
|
672
|
-
let categoryIndex = -1;
|
|
673
|
-
for (let i = keyIndex + 1; i < lines.length; i++) {
|
|
674
|
-
const line = lines[i];
|
|
675
|
-
if (typeof line !== 'string')
|
|
676
|
-
break; // EOF — block ended
|
|
677
|
-
if (line.trim() === '' || /^\s*#/.test(line))
|
|
678
|
-
continue; // blank / comment — transparent, inside the block
|
|
679
|
-
if (!/^ {4}/.test(line))
|
|
680
|
-
break; // dedent — mapping entry ended
|
|
681
|
-
if (WORKBUDDY_ENABLED_LINE.test(line)) {
|
|
682
|
-
enabledIndex = i;
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
if (WORKBUDDY_CATEGORY_LINE.test(line)) {
|
|
686
|
-
categoryIndex = i;
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
// Any other legal field (source: …) or nested content — inside the
|
|
690
|
-
// block: keep scanning, never touch it.
|
|
691
|
-
}
|
|
692
|
-
if (enabledIndex >= 0) {
|
|
693
|
-
// In-place flip; nothing else in the block moves.
|
|
694
|
-
lines[enabledIndex] = ` enabled: ${enabled}`;
|
|
695
|
-
}
|
|
696
|
-
else {
|
|
697
|
-
// No enabled field anywhere in the block: insert it under the key line.
|
|
698
|
-
lines.splice(keyIndex + 1, 0, ` enabled: ${enabled}`);
|
|
699
|
-
if (categoryIndex > keyIndex)
|
|
700
|
-
categoryIndex += 1;
|
|
701
|
-
}
|
|
702
|
-
// R1 write contract: EVERY entry this function writes carries the
|
|
703
|
-
// schema-required category (production validator requirement). Insert it
|
|
704
|
-
// only when the block has none — never alongside an existing one.
|
|
705
|
-
if (categoryIndex >= 0) {
|
|
706
|
-
lines[categoryIndex] = ` category: ${WORKBUDDY_FLAG_CATEGORY}`;
|
|
707
|
-
}
|
|
708
|
-
else if (enabledIndex >= 0) {
|
|
709
|
-
lines.splice(enabledIndex + 1, 0, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
|
|
710
|
-
}
|
|
711
|
-
else {
|
|
712
|
-
lines.splice(keyIndex + 2, 0, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
else {
|
|
716
|
-
// Match BOTH an existing mapping header (`features:`) and the empty flow
|
|
717
|
-
// mapping the installer template writes (`features: {}`) — appending a
|
|
718
|
-
// SECOND features block instead would create a duplicate YAML key.
|
|
719
|
-
const featuresIndex = lines.findIndex((line) => FEATURES_LINE.test(line) || FEATURES_EMPTY_MAPPING_LINE.test(line));
|
|
720
|
-
if (featuresIndex >= 0) {
|
|
721
|
-
const featuresLine = lines[featuresIndex];
|
|
722
|
-
if (typeof featuresLine === 'string' && isEmptyFeaturesMapping(featuresLine)) {
|
|
723
|
-
lines[featuresIndex] = 'features:';
|
|
724
|
-
}
|
|
725
|
-
lines.splice(featuresIndex + 1, 0, ` host.workbuddy:`, ` enabled: ${enabled}`, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
// No features: mapping at all — append a block at the end.
|
|
729
|
-
lines.push('features:', ` host.workbuddy:`, ` enabled: ${enabled}`, ` category: ${WORKBUDDY_FLAG_CATEGORY}`);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
const next = lines.join('\n');
|
|
733
|
-
// Round-trip verification: the rewritten config must parse with the flag at
|
|
734
|
-
// the intended value AND the registry-required category before it is
|
|
735
|
-
// committed (same discipline as codex-setup).
|
|
736
|
-
let verify;
|
|
737
|
-
try {
|
|
738
|
-
verify = yaml.load(next);
|
|
739
|
-
}
|
|
740
|
-
catch (error) {
|
|
741
|
-
return { ok: false, reason: `config_rewrite_invalid: verification parse failed (${error instanceof Error ? error.message : String(error)}). Nothing was written.`, nextAction: 'Report this failure — the flag edit produced invalid YAML.' };
|
|
742
|
-
}
|
|
743
|
-
const flagOk = isRecord(verify)
|
|
744
|
-
&& isRecord(verify.features)
|
|
745
|
-
&& isRecord(verify.features['host.workbuddy'])
|
|
746
|
-
&& verify.features['host.workbuddy'].enabled === enabled
|
|
747
|
-
&& verify.features['host.workbuddy'].category === WORKBUDDY_FLAG_CATEGORY;
|
|
748
|
-
if (!flagOk) {
|
|
749
|
-
return { ok: false, reason: 'config_rewrite_mismatch: round-trip verification did not observe the intended flag value + category. Nothing was written.', nextAction: 'Report this failure with the config.yaml content.' };
|
|
750
|
-
}
|
|
1005
|
+
const outcome = applyFeatureFlagLineEdit(raw, { flagId, enabled, category, configPath });
|
|
1006
|
+
if (!outcome.ok)
|
|
1007
|
+
return outcome;
|
|
751
1008
|
try {
|
|
752
1009
|
mkdirSync(configDir, { recursive: true });
|
|
753
|
-
atomicReplaceTextFile(configPath, next);
|
|
1010
|
+
atomicReplaceTextFile(configPath, outcome.next);
|
|
754
1011
|
}
|
|
755
1012
|
catch (error) {
|
|
756
1013
|
return { ok: false, reason: `config_write_failed: ${configPath} (${error instanceof Error ? error.message : String(error)})`, nextAction: 'Fix permissions/free the file and retry.' };
|
|
757
1014
|
}
|
|
758
1015
|
return { ok: true, configPath, enabled };
|
|
759
1016
|
}
|
|
1017
|
+
export function setWorkbuddyGovernanceFlag(governanceDir, enabled) {
|
|
1018
|
+
return setWorkbuddyFeatureFlag(governanceDir, { flagId: 'host.workbuddy', enabled, category: WORKBUDDY_FLAG_CATEGORY });
|
|
1019
|
+
}
|
|
760
1020
|
/** Read the explicit host.workbuddy override state (null = no explicit override → registry default OFF). */
|
|
761
1021
|
export function readWorkbuddyGovernanceFlagOverride(governanceDir) {
|
|
762
1022
|
const configPath = path.join(governanceDir, '.pd', 'config.yaml');
|
|
@@ -1345,7 +1605,7 @@ export class WorkBuddyHostInstaller {
|
|
|
1345
1605
|
// unaffected when the MCP component is missing or the write fails.
|
|
1346
1606
|
const mcpBinding = writeWorkbuddyMcpBinding(p.marketplaceRoot, this.deps.homeDir());
|
|
1347
1607
|
// 5. Register: engine commands first; observable direct-write fallback.
|
|
1348
|
-
const registration = this.registerPlugin(details, payload.version);
|
|
1608
|
+
const registration = this.registerPlugin(details, payload.version, payload.defaultEnabled);
|
|
1349
1609
|
if (!registration.ok) {
|
|
1350
1610
|
return {
|
|
1351
1611
|
success: false,
|
|
@@ -1441,12 +1701,15 @@ export class WorkBuddyHostInstaller {
|
|
|
1441
1701
|
hostId: this.hostId,
|
|
1442
1702
|
configPath: p.settingsPath,
|
|
1443
1703
|
configAction: registration.settingsAction,
|
|
1704
|
+
pluginState: this.readInstalledPluginState(),
|
|
1444
1705
|
governance: governanceDefaults === null
|
|
1445
1706
|
? undefined
|
|
1446
1707
|
: {
|
|
1447
1708
|
enabled: !governanceDefaults.hostExplicitOff,
|
|
1448
1709
|
explicitOptOut: governanceDefaults.hostExplicitOff,
|
|
1449
|
-
|
|
1710
|
+
// Read back from the config after the migration (OFF by contract),
|
|
1711
|
+
// never inferred from the Owner opt-out flag.
|
|
1712
|
+
keywordPainEnabled: governanceDefaults.keywordPainEnabled,
|
|
1450
1713
|
configPath: governanceDefaults.configPath,
|
|
1451
1714
|
},
|
|
1452
1715
|
reason: `Registered ${WORKBUDDY_PLUGIN_KEY} via ${registration.mode}${registration.mode === 'engine' ? '' : ` (engine unreachable: ${registration.engineNote})`}. Governance workspace initialized at ${p.governanceDir}${governance.dbInit === 'skipped' ? ` (${governance.note})` : ''}.${profileNote}${governanceDefaultsNote}${mcpBinding.ok ? ' pd-recall MCP binding written.' : ` pd-recall MCP binding skipped (${mcpBinding.reason ?? 'unknown'}).`}`,
|
|
@@ -1454,7 +1717,7 @@ export class WorkBuddyHostInstaller {
|
|
|
1454
1717
|
// main; the "Existing workspaces retain their prior settings" sentence is
|
|
1455
1718
|
// deliberately dropped — PRI-1064 turns governance ON for those
|
|
1456
1719
|
// workspaces, so keeping it would state the opposite of what happens.
|
|
1457
|
-
nextAction: `${effectiveNote}${governance.dbInit === 'skipped' ? ` After the install, run "pd runtime init --workspace ${p.governanceDir}" to create the governance databases.` : ''} To disable, edit ${path.join(p.governanceDir, '.pd', 'config.yaml')} — each flag id is ONE flat key (not nested). In the existing "features:" block set
|
|
1720
|
+
nextAction: `${effectiveNote}${governance.dbInit === 'skipped' ? ` After the install, run "pd runtime init --workspace ${p.governanceDir}" to create the governance databases.` : ''} To disable, edit ${path.join(p.governanceDir, '.pd', 'config.yaml')} — each flag id is ONE flat key (not nested). In the existing "features:" block set host.workbuddy: {enabled: false} to disable all WorkBuddy governance. Automatic Pain detection is unavailable in WorkBuddy; record pain only through the explicit "PD记录痛苦:" prefix.${pendingRebindNote}`,
|
|
1458
1721
|
};
|
|
1459
1722
|
}
|
|
1460
1723
|
/**
|
|
@@ -1531,20 +1794,32 @@ export class WorkBuddyHostInstaller {
|
|
|
1531
1794
|
note: `governance database initialization SKIPPED: pd-cli runtime entry not found — run "pd runtime init --workspace ${governanceDir}" after the install; until then pain evidence degrades with an explicit trajectory_db_not_found note.`,
|
|
1532
1795
|
};
|
|
1533
1796
|
}
|
|
1534
|
-
registerPlugin(details, version) {
|
|
1797
|
+
registerPlugin(details, version, declaredDefaultEnabled) {
|
|
1535
1798
|
const p = this.paths();
|
|
1536
1799
|
// A registered old plugin can still be hook-only, even at the same
|
|
1537
1800
|
// manifest version. Reuse the installer-owned registration/cache writer
|
|
1538
1801
|
// rather than reporting a stale cache as an idempotent successful install.
|
|
1539
1802
|
if (details.registered && !this.pluginCacheMatchesPayload(version)) {
|
|
1540
|
-
return this.directWriteRegistration(details, version,
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1803
|
+
return this.directWriteRegistration(details, version, {
|
|
1804
|
+
declaredDefaultEnabled,
|
|
1805
|
+
engineNote: 'registered plugin cache differs from the current bundled payload; refreshed by installer',
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
// Idempotent fast path: already registered AND cached. The installed
|
|
1809
|
+
// enabled state is the OWNER's choice — install/upgrade/reinstall must not
|
|
1810
|
+
// flip it in either direction (install ≠ enable, SPEC §6.4).
|
|
1811
|
+
if (details.registered && existsSync(p.cachePluginRoot)) {
|
|
1544
1812
|
return { ok: true, mode: 'already-registered', settingsAction: 'preserved' };
|
|
1545
1813
|
}
|
|
1546
1814
|
// Engine path (preferred — real marketplace/cache/settings chain).
|
|
1547
1815
|
if (details.engineFound) {
|
|
1816
|
+
// Resolve the installed enabled value BEFORE the engine runs: an engine
|
|
1817
|
+
// install must not enable the plugin, and a pre-existing Owner choice
|
|
1818
|
+
// (true or false) must survive the install.
|
|
1819
|
+
const installedEnabled = this.resolveInstalledEnabledValue(declaredDefaultEnabled);
|
|
1820
|
+
if (!installedEnabled.ok) {
|
|
1821
|
+
return { ok: false, reason: installedEnabled.reason, nextAction: installedEnabled.nextAction };
|
|
1822
|
+
}
|
|
1548
1823
|
const add = this.runEngine(['plugin', 'marketplace', 'add', p.marketplaceRoot]);
|
|
1549
1824
|
if (add !== undefined && add.status === 0 && MARKETPLACE_ADD_OK.test(add.stdout + add.stderr)) {
|
|
1550
1825
|
if (details.registered) {
|
|
@@ -1554,28 +1829,26 @@ export class WorkBuddyHostInstaller {
|
|
|
1554
1829
|
}
|
|
1555
1830
|
const install = this.runEngine(['plugin', 'install', WORKBUDDY_PLUGIN_KEY]);
|
|
1556
1831
|
if (install !== undefined && install.status === 0 && PLUGIN_INSTALL_OK.test(install.stdout + install.stderr)) {
|
|
1557
|
-
|
|
1558
|
-
if (after.registered
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
// OFF either way (OD-03: features.host.workbuddy is a separate,
|
|
1566
|
-
// later, user-visible enable).
|
|
1567
|
-
const enabled = this.enableRegisteredPlugin();
|
|
1568
|
-
if (!enabled.ok) {
|
|
1569
|
-
return { ok: false, reason: enabled.reason, nextAction: enabled.nextAction };
|
|
1832
|
+
const after = this.detectDetails();
|
|
1833
|
+
if (after.registered) {
|
|
1834
|
+
// Converge the settings entry onto the resolved value. This covers
|
|
1835
|
+
// engines that would force-enable on install while still preserving
|
|
1836
|
+
// an Owner-chosen true/false from before the install.
|
|
1837
|
+
const applied = this.applyInstalledEnabledValue(installedEnabled.value);
|
|
1838
|
+
if (!applied.ok) {
|
|
1839
|
+
return { ok: false, reason: applied.reason, nextAction: applied.nextAction };
|
|
1570
1840
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1841
|
+
return {
|
|
1842
|
+
ok: true,
|
|
1843
|
+
mode: 'engine',
|
|
1844
|
+
settingsAction: details.settingsExisted
|
|
1845
|
+
? (installedEnabled.fromOwnerChoice ? 'preserved' : 'updated')
|
|
1846
|
+
: 'created',
|
|
1847
|
+
};
|
|
1575
1848
|
}
|
|
1576
1849
|
return {
|
|
1577
1850
|
ok: false,
|
|
1578
|
-
reason: `workbuddy_engine_install_unverified: engine reported success but
|
|
1851
|
+
reason: `workbuddy_engine_install_unverified: engine reported success but installed_plugins.json does not carry ${WORKBUDDY_PLUGIN_KEY} afterwards.`,
|
|
1579
1852
|
nextAction: `Run "node <engine> plugin list" to inspect the host state, then re-run the installer.`,
|
|
1580
1853
|
};
|
|
1581
1854
|
}
|
|
@@ -1590,14 +1863,17 @@ export class WorkBuddyHostInstaller {
|
|
|
1590
1863
|
const note = add !== undefined
|
|
1591
1864
|
? `engine marketplace add failed: ${((add.stderr || add.stdout).trim().slice(0, 200) || `exit ${add.status}`)}`
|
|
1592
1865
|
: 'engine invocation failed';
|
|
1593
|
-
const direct = this.directWriteRegistration(details, version, note);
|
|
1866
|
+
const direct = this.directWriteRegistration(details, version, { declaredDefaultEnabled, engineNote: note });
|
|
1594
1867
|
if (direct.ok)
|
|
1595
1868
|
return direct;
|
|
1596
1869
|
return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
|
|
1597
1870
|
}
|
|
1598
1871
|
// Engine unreachable: direct-write path, strictly aligned with the frozen
|
|
1599
1872
|
// formats, and always reported as a distinct mode.
|
|
1600
|
-
const direct = this.directWriteRegistration(details, version,
|
|
1873
|
+
const direct = this.directWriteRegistration(details, version, {
|
|
1874
|
+
declaredDefaultEnabled,
|
|
1875
|
+
engineNote: 'engine binary not found',
|
|
1876
|
+
});
|
|
1601
1877
|
if (direct.ok)
|
|
1602
1878
|
return direct;
|
|
1603
1879
|
return { ok: false, reason: direct.reason, nextAction: direct.nextAction };
|
|
@@ -1626,33 +1902,75 @@ export class WorkBuddyHostInstaller {
|
|
|
1626
1902
|
}
|
|
1627
1903
|
}
|
|
1628
1904
|
/**
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
* defaultEnabled) onto the installer's frozen installed ⇒ enabled contract.
|
|
1905
|
+
* The value an install should leave behind: an explicit boolean already in
|
|
1906
|
+
* settings.json is the Owner's choice and always wins; otherwise the plugin
|
|
1907
|
+
* manifest's `defaultEnabled` applies. Install never enables by itself.
|
|
1633
1908
|
*/
|
|
1634
|
-
|
|
1909
|
+
resolveInstalledEnabledValue(declaredDefaultEnabled) {
|
|
1910
|
+
const p = this.paths();
|
|
1911
|
+
const settings = readHostJsonFile(p.settingsPath);
|
|
1912
|
+
if (!settings.ok)
|
|
1913
|
+
return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
|
|
1914
|
+
const current = isRecord(settings.value?.enabledPlugins)
|
|
1915
|
+
? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
|
|
1916
|
+
: undefined;
|
|
1917
|
+
if (typeof current === 'boolean')
|
|
1918
|
+
return { ok: true, value: current, fromOwnerChoice: true };
|
|
1919
|
+
return { ok: true, value: declaredDefaultEnabled, fromOwnerChoice: false };
|
|
1920
|
+
}
|
|
1921
|
+
/** Write enabledPlugins[OUR_KEY]=value, preserving every other key. */
|
|
1922
|
+
applyInstalledEnabledValue(value) {
|
|
1635
1923
|
const p = this.paths();
|
|
1636
1924
|
const settings = readHostJsonFile(p.settingsPath);
|
|
1637
1925
|
if (!settings.ok)
|
|
1638
1926
|
return { ok: false, reason: settings.reason, nextAction: settings.nextAction };
|
|
1927
|
+
const current = isRecord(settings.value?.enabledPlugins)
|
|
1928
|
+
? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
|
|
1929
|
+
: undefined;
|
|
1930
|
+
if (typeof current === 'boolean' && current === value)
|
|
1931
|
+
return { ok: true, changed: false };
|
|
1639
1932
|
const nextSettings = { ...(settings.value ?? {}) };
|
|
1640
1933
|
const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
|
|
1641
|
-
enabledPlugins[WORKBUDDY_PLUGIN_KEY] =
|
|
1934
|
+
enabledPlugins[WORKBUDDY_PLUGIN_KEY] = value;
|
|
1642
1935
|
nextSettings.enabledPlugins = enabledPlugins;
|
|
1643
1936
|
const write = writeHostJsonFileProtected(p.settingsPath, JSON.stringify(nextSettings, null, 2) + '\n');
|
|
1644
1937
|
if (!write.ok)
|
|
1645
1938
|
return { ok: false, reason: write.reason, nextAction: write.nextAction };
|
|
1646
|
-
return { ok: true };
|
|
1939
|
+
return { ok: true, changed: true };
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Read the two INDEPENDENT post-install facts straight from the host:
|
|
1943
|
+
* - registration → installed_plugins.json (host registry);
|
|
1944
|
+
* - enablement → settings.json enabledPlugins[our key].
|
|
1945
|
+
* Neither may be inferred from the other, from the governance flag, or from
|
|
1946
|
+
* the engine's exit status. An unreadable registry must NOT be reported as
|
|
1947
|
+
* "installed" (rc-9).
|
|
1948
|
+
*/
|
|
1949
|
+
readInstalledPluginState() {
|
|
1950
|
+
const p = this.paths();
|
|
1951
|
+
const registry = readHostJsonFile(p.registryPath);
|
|
1952
|
+
const registered = registry.ok && registry.existed && isRecord(registry.value.plugins)
|
|
1953
|
+
? Object.hasOwn(registry.value.plugins, WORKBUDDY_PLUGIN_KEY)
|
|
1954
|
+
: false;
|
|
1955
|
+
const settings = readHostJsonFile(p.settingsPath);
|
|
1956
|
+
const enabledPlugins = settings.ok && isRecord(settings.value?.enabledPlugins) ? settings.value.enabledPlugins : undefined;
|
|
1957
|
+
const raw = enabledPlugins === undefined ? undefined : enabledPlugins[WORKBUDDY_PLUGIN_KEY];
|
|
1958
|
+
return {
|
|
1959
|
+
registered,
|
|
1960
|
+
registryReadable: registry.ok,
|
|
1961
|
+
enabled: typeof raw === 'boolean' ? raw : null,
|
|
1962
|
+
};
|
|
1647
1963
|
}
|
|
1648
1964
|
/**
|
|
1649
1965
|
* Direct-write registration for engine-unreachable machines, aligned with the
|
|
1650
|
-
* frozen P01 formats: enabledPlugins[key]
|
|
1651
|
-
*
|
|
1966
|
+
* frozen P01 formats: enabledPlugins[key] carries the manifest default or the
|
|
1967
|
+
* Owner's existing choice (install never enables by itself; other keys and
|
|
1968
|
+
* other plugins preserved), installed_plugins.json v2 user registration
|
|
1652
1969
|
* (native scope array or legacy object), and the versioned cache
|
|
1653
1970
|
* copy under plugins/cache/<marketplace>/<plugin>/<version>/.
|
|
1654
1971
|
*/
|
|
1655
|
-
directWriteRegistration(details, version,
|
|
1972
|
+
directWriteRegistration(details, version, options) {
|
|
1973
|
+
const { declaredDefaultEnabled, engineNote } = options;
|
|
1656
1974
|
const p = this.paths();
|
|
1657
1975
|
const cacheVersionDir = workbuddyCacheVersionDir(p.cachePluginRoot, version);
|
|
1658
1976
|
if (cacheVersionDir === undefined) {
|
|
@@ -1679,9 +1997,15 @@ export class WorkBuddyHostInstaller {
|
|
|
1679
1997
|
}
|
|
1680
1998
|
// settings.json merge — computed here, WRITTEN after the cache lands (see
|
|
1681
1999
|
// below): a failed payload must leave no registration state behind.
|
|
2000
|
+
// Install ≠ enable (SPEC §6.4): an explicit Owner choice is preserved;
|
|
2001
|
+
// otherwise the manifest's defaultEnabled applies.
|
|
2002
|
+
const currentEnabled = isRecord(settings.value?.enabledPlugins)
|
|
2003
|
+
? settings.value.enabledPlugins[WORKBUDDY_PLUGIN_KEY]
|
|
2004
|
+
: undefined;
|
|
2005
|
+
const installedEnabled = typeof currentEnabled === 'boolean' ? currentEnabled : declaredDefaultEnabled;
|
|
1682
2006
|
const nextSettings = { ...(settings.value ?? {}) };
|
|
1683
2007
|
const enabledPlugins = isRecord(nextSettings.enabledPlugins) ? { ...nextSettings.enabledPlugins } : {};
|
|
1684
|
-
enabledPlugins[WORKBUDDY_PLUGIN_KEY] =
|
|
2008
|
+
enabledPlugins[WORKBUDDY_PLUGIN_KEY] = installedEnabled;
|
|
1685
2009
|
nextSettings.enabledPlugins = enabledPlugins;
|
|
1686
2010
|
const settingsAction = settings.existed ? 'updated' : 'created';
|
|
1687
2011
|
// installed_plugins.json v2 merge (create with version 2 when absent)
|