@stravica-ai/digital-operator-pa 0.1.34 → 0.1.36
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/dist/engineering-pa-cli/src/secrets/commands/get.mjs +2 -2
- package/dist/engineering-pa-cli/src/secrets/commands/list.mjs +2 -2
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +77 -0
- package/dist/engineering-pa-core/docs/tools/slice.md +30 -9
- package/dist/engineering-pa-core/src/slice/cli.mjs +12 -0
- package/dist/engineering-pa-core/src/slice/prompts/dave-boot.md +35 -33
- package/dist/engineering-pa-core/src/slice/run.mjs +142 -4
- package/dist/engineering-pa-library-cli/package.json +1 -1
- package/dist/engineering-pa-library-cli/src/cli.mjs +53 -1
- package/dist/engineering-pa-library-cli/src/envelope.mjs +95 -24
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
depInstallMessage,
|
|
11
11
|
checkVault,
|
|
12
12
|
secretsFileExists,
|
|
13
|
-
|
|
13
|
+
sopsDecryptOrReport,
|
|
14
14
|
readVaultKeyNames,
|
|
15
15
|
VaultError,
|
|
16
16
|
} from '../vault.mjs';
|
|
@@ -70,7 +70,7 @@ export async function runGet(args, overrides = {}) {
|
|
|
70
70
|
throw new VaultError('missing dependencies', 1);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const data = await
|
|
73
|
+
const data = await sopsDecryptOrReport(paths, paths.secretsFile);
|
|
74
74
|
if (!(key in data)) {
|
|
75
75
|
console.error(`Error: Key "${key}" not found.`);
|
|
76
76
|
throw new VaultError('key not found', 1);
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
depInstallMessage,
|
|
7
7
|
checkVault,
|
|
8
8
|
secretsFileExists,
|
|
9
|
-
|
|
9
|
+
sopsDecryptOrReport,
|
|
10
10
|
VaultError,
|
|
11
11
|
} from '../vault.mjs';
|
|
12
12
|
|
|
@@ -30,7 +30,7 @@ export async function runList(overrides = {}) {
|
|
|
30
30
|
return 0;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const data = await
|
|
33
|
+
const data = await sopsDecryptOrReport(paths, paths.secretsFile);
|
|
34
34
|
const keys = Object.keys(data).sort();
|
|
35
35
|
for (const key of keys) console.log(key);
|
|
36
36
|
return 0;
|
|
@@ -782,6 +782,83 @@ export async function sopsDecrypt(paths, filePath) {
|
|
|
782
782
|
return JSON.parse(stdout);
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* Parse the age recipient public keys out of a sops config file. Kept
|
|
787
|
+
* deliberately narrow: reads every line of the YAML at `sopsConfigPath`
|
|
788
|
+
* and extracts anything shaped like `age1[0-9a-z]+` (matches both inline
|
|
789
|
+
* `age: age1abc...` and the multi-line `age: >-\n age1abc...` shapes the
|
|
790
|
+
* scoped-vault fixtures use). Order is stable, duplicates deduplicated.
|
|
791
|
+
* Returns an empty array on any read failure. This helper only feeds a
|
|
792
|
+
* diagnostic message and must never throw over its own IO.
|
|
793
|
+
*/
|
|
794
|
+
export function readRecipientsFromSopsConfig(sopsConfigPath) {
|
|
795
|
+
let text;
|
|
796
|
+
try {
|
|
797
|
+
text = readFileSync(sopsConfigPath, 'utf-8');
|
|
798
|
+
} catch {
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
const out = [];
|
|
802
|
+
const seen = new Set();
|
|
803
|
+
const re = /age1[0-9a-z]+/g;
|
|
804
|
+
let m;
|
|
805
|
+
while ((m = re.exec(text)) !== null) {
|
|
806
|
+
const value = m[0];
|
|
807
|
+
if (!seen.has(value)) {
|
|
808
|
+
seen.add(value);
|
|
809
|
+
out.push(value);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Build the single stderr diagnostic line that names, without ever
|
|
817
|
+
* decrypting anything, WHY a decrypt failed: the scope (or vault path),
|
|
818
|
+
* the caller's age public key (never the private key, never the value),
|
|
819
|
+
* and the scope's recipient list read from the sops config. Falls back
|
|
820
|
+
* to a shorter form when one of those pieces cannot be recovered. The
|
|
821
|
+
* caller still needs a message, and silence on this seam is the defect
|
|
822
|
+
* being fixed (w-2026-09-02-dave-015 part 1).
|
|
823
|
+
*/
|
|
824
|
+
export function formatDecryptFailure(paths) {
|
|
825
|
+
const target = paths.scope
|
|
826
|
+
? `scope "${paths.scope}"`
|
|
827
|
+
: (paths.secretsFile ? `vault ${paths.secretsFile}` : 'vault');
|
|
828
|
+
let callerPub = null;
|
|
829
|
+
try {
|
|
830
|
+
callerPub = publicKeyFromKeyFile(paths.keyFile);
|
|
831
|
+
} catch { /* no key file, or unreadable: leave callerPub null */ }
|
|
832
|
+
const callerDesc = callerPub
|
|
833
|
+
? `age key ${callerPub}`
|
|
834
|
+
: `age key at ${paths.keyFile}`;
|
|
835
|
+
const recipients = paths.sopsConfig ? readRecipientsFromSopsConfig(paths.sopsConfig) : [];
|
|
836
|
+
const recipientsDesc = recipients.length > 0
|
|
837
|
+
? `recipients (from ${paths.sopsConfig}): ${recipients.join(', ')}`
|
|
838
|
+
: `recipients could not be read from ${paths.sopsConfig ?? '<no sops config>'}`;
|
|
839
|
+
return `Error: cannot decrypt ${target}: this host's ${callerDesc} is not in the recipient list; ${recipientsDesc}. Fix: add this host's public key to the scope's recipients, or run this verb from a host that already has decrypt access.`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Decrypt wrapper for the interactive verbs (get, list). On failure it
|
|
844
|
+
* prints the {@link formatDecryptFailure} one-liner to stderr and
|
|
845
|
+
* re-throws with exitCode 2 preserved, so main() exits with the correct
|
|
846
|
+
* code AND the caller sees why, closing the silent-exit-2 seam
|
|
847
|
+
* (w-2026-09-02-dave-015 part 1). Never handles the decrypted value on
|
|
848
|
+
* the failure path; on success it returns exactly what sopsDecrypt did.
|
|
849
|
+
*/
|
|
850
|
+
export async function sopsDecryptOrReport(paths, filePath) {
|
|
851
|
+
try {
|
|
852
|
+
return await sopsDecrypt(paths, filePath);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
console.error(formatDecryptFailure(paths));
|
|
855
|
+
if (err && typeof err === 'object' && err.exitCode == null) {
|
|
856
|
+
err.exitCode = 2;
|
|
857
|
+
}
|
|
858
|
+
throw err;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
785
862
|
/** Encrypt a JSON object to the secrets file with the given public key recipient. */
|
|
786
863
|
export async function sopsEncrypt(paths, pubKey, obj) {
|
|
787
864
|
const tmp = mkdtempSync(join(tmpdir(), 'vault-'));
|
|
@@ -21,7 +21,10 @@ Implemented: `generate`, `generate --dry-run`, `--help`.
|
|
|
21
21
|
Bundled templates:
|
|
22
22
|
|
|
23
23
|
- `dave-boot` — the original use case. Six-section operator-boot card aimed
|
|
24
|
-
at single-operator PA instances like Dave.
|
|
24
|
+
at single-operator PA instances like Dave. Sections: Operator basics,
|
|
25
|
+
Mission framing, Active relationships, Operational constraints, Tools at
|
|
26
|
+
your disposal, Live state. Post-generate lint enforces the 2026-09-03
|
|
27
|
+
contract (w-2026-07-15-012 / w-2026-09-04-dave-003).
|
|
25
28
|
|
|
26
29
|
Custom templates are loaded with `--template <path>`.
|
|
27
30
|
|
|
@@ -59,14 +62,26 @@ Optional inputs:
|
|
|
59
62
|
with `shell: false` — no shell interpolation, the inputs are safe even if
|
|
60
63
|
they contain shell metacharacters.
|
|
61
64
|
4. Validate the response against the required-headers contract (six
|
|
62
|
-
sections: Operator basics, Mission framing, Active
|
|
63
|
-
|
|
65
|
+
sections: Operator basics, Mission framing, Active relationships,
|
|
66
|
+
Operational constraints, Tools at your disposal, Live state). A response
|
|
64
67
|
missing any required header is treated as a generator failure (exit 2)
|
|
65
|
-
and no file is written.
|
|
68
|
+
and no file is written. The 2026-09-03 rewrite (w-2026-07-15-012)
|
|
69
|
+
retired the Active projects and Load-bearing rules sections: live boot
|
|
70
|
+
state is injected by the `boot-context` hook, and operational rules live
|
|
71
|
+
in `operator/knowledge/operator-interface/operational-constraints.md`.
|
|
66
72
|
5. Validate the size against the 2 KB soft budget. Over-budget responses
|
|
67
|
-
produce a stderr warning
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
produce a stderr warning; the slice is still written when the byte
|
|
74
|
+
count sits inside the lint hard ceiling (`SIZE_LINT_HARD_BYTES`, 3000
|
|
75
|
+
B). Above that ceiling the post-generate lint refuses the write.
|
|
76
|
+
6. Run the template-specific post-generate lint (dave-boot: names the
|
|
77
|
+
platform package `@stravica-ai/digital-operator-pa`, points at the
|
|
78
|
+
constraints doc, carries no `## Active projects` or `## In-flight work`
|
|
79
|
+
block, size within the hard ceiling). A lint failure refuses the write
|
|
80
|
+
with exit 2 so a defective regeneration cannot clobber the good slice
|
|
81
|
+
in place. Pass `--no-lint` to bypass. Custom templates loaded with
|
|
82
|
+
`--template <path>` have no template-specific checks and always
|
|
83
|
+
proceed to write. w-2026-09-04-dave-003.
|
|
84
|
+
7. Write the response to the configured `--output` path. Parent directories
|
|
70
85
|
are created if missing.
|
|
71
86
|
|
|
72
87
|
#### Flags
|
|
@@ -80,6 +95,8 @@ Optional inputs:
|
|
|
80
95
|
this file. Default-off. See **Audit log** below.
|
|
81
96
|
- `--dry-run` Build the prompt and report; skip the claude
|
|
82
97
|
call and the file write.
|
|
98
|
+
- `--no-lint` Bypass the post-generate template-specific
|
|
99
|
+
lint. See step 6 above.
|
|
83
100
|
- `-h`, `--help` Show usage.
|
|
84
101
|
|
|
85
102
|
### Exit codes
|
|
@@ -105,6 +122,9 @@ Status vocabulary (closed):
|
|
|
105
122
|
- `noop-dry-run` — `--dry-run` path; no claude call, no file write.
|
|
106
123
|
- `fail-claude` — claude exited non-zero (transport failure, exit 3).
|
|
107
124
|
- `fail-validation` — claude returned content missing required headers (exit 2).
|
|
125
|
+
- `fail-lint`: post-generate template lint refused the write (exit 2). The
|
|
126
|
+
regeneration was blocked from clobbering the good slice in place.
|
|
127
|
+
w-2026-09-04-dave-003.
|
|
108
128
|
- `fail-prereq` — input loading failed (missing profile / observations / template, exit 2).
|
|
109
129
|
- `fail-write` — writing the slice output file failed (permissions, ENOSPC, etc., exit 2).
|
|
110
130
|
- `fail-usage` — early arg-validation failed (missing required option, exit 1).
|
|
@@ -134,8 +154,9 @@ A single markdown file at the `--output` path. Plain markdown, no
|
|
|
134
154
|
front-matter, no JSON envelope. The PA's boot rule reads it the same way it
|
|
135
155
|
reads the full profile.
|
|
136
156
|
|
|
137
|
-
Soft target: under 2 KB / ~30 lines. Hard
|
|
138
|
-
headers must be present
|
|
157
|
+
Soft target: under 2 KB / ~30 lines. Hard requirements: the six section
|
|
158
|
+
headers must be present, and (for the bundled `dave-boot` template) the
|
|
159
|
+
post-generate lint contract in step 6.
|
|
139
160
|
|
|
140
161
|
## Design notes
|
|
141
162
|
|
|
@@ -33,6 +33,16 @@ Options for "generate":
|
|
|
33
33
|
slice runs on a schedule, point this at a
|
|
34
34
|
per-task runs.jsonl. Optional.
|
|
35
35
|
--dry-run Build the prompt and report; skip claude call + write.
|
|
36
|
+
--no-lint Bypass the post-generate template-specific lint.
|
|
37
|
+
The lint refuses to write a slice that fails the
|
|
38
|
+
template's contract checks (dave-boot: names the
|
|
39
|
+
platform package, points at the constraints doc,
|
|
40
|
+
no baked in-flight state, size within ceiling).
|
|
41
|
+
The scheduled nightly leaves this OFF so a
|
|
42
|
+
defective regeneration cannot clobber the good
|
|
43
|
+
slice; hand-invocations may pass it when the
|
|
44
|
+
checks are known to be wrong for the current
|
|
45
|
+
contract.
|
|
36
46
|
-h, --help Show this help.
|
|
37
47
|
|
|
38
48
|
Exit codes:
|
|
@@ -89,6 +99,7 @@ async function route(argv, { stderr, stdout, exit }) {
|
|
|
89
99
|
template: { type: 'string' },
|
|
90
100
|
'runs-log': { type: 'string' },
|
|
91
101
|
'dry-run': { type: 'boolean', default: false },
|
|
102
|
+
'no-lint': { type: 'boolean', default: false },
|
|
92
103
|
help: { type: 'boolean', short: 'h', default: false },
|
|
93
104
|
},
|
|
94
105
|
strict: true,
|
|
@@ -126,6 +137,7 @@ async function route(argv, { stderr, stdout, exit }) {
|
|
|
126
137
|
outputPath: values.output,
|
|
127
138
|
runsLog: values['runs-log'],
|
|
128
139
|
dryRun: values['dry-run'],
|
|
140
|
+
noLint: values['no-lint'],
|
|
129
141
|
runner: defaultRunner,
|
|
130
142
|
stderr,
|
|
131
143
|
});
|
|
@@ -1,33 +1,32 @@
|
|
|
1
|
-
You are operating as a compression assistant. Your job is to produce a compact "boot slice" of the operator's profile
|
|
1
|
+
You are operating as a compression assistant. Your job is to produce a compact "boot slice" of the operator's profile: a short markdown document that loads into the PA's context at every session start, in place of the full profile.
|
|
2
2
|
|
|
3
3
|
The full profile is the source of truth. This slice is a fresh snapshot, regenerated periodically. Stale content in the slice is acceptable for ~24 hours.
|
|
4
4
|
|
|
5
5
|
## What the slice is for
|
|
6
6
|
|
|
7
|
-
When the PA boots, it needs to know
|
|
7
|
+
When the PA boots, it needs to know, in roughly 1 to 2 KB of context, *who it is working for*. Not every preference, not every historical decision, not every technical opinion. Just enough that:
|
|
8
8
|
|
|
9
9
|
- It addresses the operator correctly (name, register, tone).
|
|
10
10
|
- It knows the operator's mission framing this year.
|
|
11
|
-
- It knows
|
|
11
|
+
- It knows the standing relationships that carry across sessions.
|
|
12
12
|
- It is aware of the tools and capabilities already built and available.
|
|
13
|
-
- It
|
|
14
|
-
- It can read the rest of the profile or knowledge base when it needs detail.
|
|
13
|
+
- It picks up operational constraints from the canonical constraints doc, not from a stale slice copy.
|
|
15
14
|
|
|
16
|
-
This is a navigation card, not the territory. **Prefer pointers to canonical sources over baked-in content** that will go stale between regenerations.
|
|
15
|
+
This is a navigation card, not the territory. **Prefer pointers to canonical sources over baked-in content** that will go stale between regenerations. Live boot state (active projects, in-flight work) is injected by the `boot-context` hook at session start, so the slice must NOT carry that state.
|
|
17
16
|
|
|
18
17
|
## Inputs you have
|
|
19
18
|
|
|
20
19
|
You are given:
|
|
21
20
|
|
|
22
|
-
1. **The full operator profile** (`profile.md`)
|
|
23
|
-
2. **Recent corrections / load-bearing rules** (optional, `learned-rules.md`)
|
|
24
|
-
3. **Recent observations** (optional)
|
|
21
|
+
1. **The full operator profile** (`profile.md`): the source of truth, ~18 KB.
|
|
22
|
+
2. **Recent corrections / load-bearing rules** (optional, `learned-rules.md`): ~50 KB of operational rules accumulated from corrections. Use only to inform vocabulary and register; do NOT bake rules into the slice.
|
|
23
|
+
3. **Recent observations** (optional): a concatenated dump of raw observation files filed in the last 2 to 4 weeks, useful for "what's been on his mind lately" but not authoritative.
|
|
25
24
|
|
|
26
|
-
Treat the profile as authoritative. Treat learned-rules as second-tier authoritative (they ARE rules). Treat observations as raw signal
|
|
25
|
+
Treat the profile as authoritative. Treat learned-rules as second-tier authoritative (they ARE rules, but they live in their own file: do not re-state them here). Treat observations as raw signal: synthesise, do not quote.
|
|
27
26
|
|
|
28
27
|
## Output structure
|
|
29
28
|
|
|
30
|
-
Produce a markdown document with exactly these
|
|
29
|
+
Produce a markdown document with exactly these six sections, in this order:
|
|
31
30
|
|
|
32
31
|
```
|
|
33
32
|
# Operator Boot Slice -- <Operator Name>
|
|
@@ -35,56 +34,59 @@ Produce a markdown document with exactly these seven sections, in this order:
|
|
|
35
34
|
_Generated <ISO timestamp>. Refreshed periodically. Source of truth: operator/profile/profile.md._
|
|
36
35
|
|
|
37
36
|
## Operator basics
|
|
38
|
-
<3
|
|
37
|
+
<3 to 5 bullets: name, what they go by, communication register, tone preferences, sensitivities>
|
|
39
38
|
|
|
40
39
|
## Mission framing
|
|
41
|
-
<1
|
|
42
|
-
|
|
43
|
-
## Active projects
|
|
44
|
-
<3-6 bullets, one line each: project name -- one-line status>
|
|
40
|
+
<1 to 2 lines: the north-star and what the operator is building toward this year. Name the PA platform's ship-vehicle plainly. The platform ships as `@stravica-ai/digital-operator-pa` on npm; if the profile confirms this, say so.>
|
|
45
41
|
|
|
46
42
|
## Active relationships
|
|
47
|
-
<2
|
|
43
|
+
<2 to 4 bullets, one line each: who -- relationship context. Standing relationships only, not one-off collaborations.>
|
|
44
|
+
|
|
45
|
+
## Operational constraints (session-load)
|
|
46
|
+
|
|
47
|
+
Read `operator/knowledge/operator-interface/operational-constraints.md` on every boot. That doc carries network, infrastructure, tooling, estate and voice constraints and standing corrections. It is the one place these live; anything you find restated in this slice or in `profile.md` is a defect, escalate it under the routing rule in `operator/knowledge/knowledge-memory/share-scope-routing.md`.
|
|
48
48
|
|
|
49
49
|
## Tools at your disposal
|
|
50
|
-
<2-3 lines: a one-line summary of the HQ-built operator-extension tools you have available (look for evidence of these in the inputs — names like wsd-ingest, dispatch, svc, monitoring, scheduled cron tasks, etc.), followed by a pointer line to the canonical list:>
|
|
51
|
-
For the canonical inventory of operator-built tools and scheduled tasks, read `operator/knowledge/systems/operator-tools.md`.
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
<2 to 3 lines: a one-line summary of the platform bins (via `pnpm exec <bin>`; the platform ships as `@stravica-ai/digital-operator-pa`) and the HQ operator-extension tools available, followed by a pointer line:>
|
|
52
|
+
For the current tool surface each session, discover via `ls operator/extensions/tools/` and `pnpm exec pa --help`; never rely on memory.
|
|
53
|
+
|
|
54
|
+
## Live state -- fetch on boot
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
For
|
|
58
|
-
|
|
56
|
+
For current projects and in-flight work items: `pnpm exec desk overview`.
|
|
57
|
+
For the last session's boundary notes: `agents/dave/next-session.md`.
|
|
58
|
+
For recent dispatches: tail `agents/dave/spawn-log.json`.
|
|
59
|
+
The `boot-context` hook injects a live `workState` block on session start; trust that first, hunt files only if the hook did not fire.
|
|
60
|
+
Do not bake state into this slice, it will be wrong by the time it is read.
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
The
|
|
63
|
+
The **Operational constraints** section and the **Live state** section are **fixed instructions**, not generated content. Reproduce them verbatim. The point is to send the PA to the canonical constraints doc for any operational rule, and to the live tools for any state question, rather than relying on a frozen snapshot.
|
|
62
64
|
|
|
63
65
|
## Budget
|
|
64
66
|
|
|
65
|
-
**Soft target: under 2 KB total output.** Aim for ~1.2
|
|
67
|
+
**Soft target: under 2 KB total output.** Aim for ~1.2 to 1.8 KB. If a section will not fit in the budget, prefer fewer items over shorter items: better to give 3 sharp lines than 6 mushy ones. Over-budget is warned; under-information is more costly.
|
|
66
68
|
|
|
67
69
|
## Style
|
|
68
70
|
|
|
69
71
|
- No emojis (the operator has a strict no-emoji rule for persisted artefacts).
|
|
70
|
-
- No em-dashes
|
|
72
|
+
- No em-dashes anywhere. Use a comma, a full stop, or a colon. The double-hyphen `--` in the file title and the "Live state -- fetch on boot" heading is intentional and must be reproduced verbatim.
|
|
71
73
|
- Use the operator's own vocabulary where you can pull it cleanly from the profile.
|
|
72
|
-
-
|
|
74
|
+
- Do not editorialise. State, do not sell.
|
|
73
75
|
- "Operator" not "user". "PA" or the PA's name, not "the assistant".
|
|
74
76
|
|
|
75
77
|
## What NOT to include
|
|
76
78
|
|
|
77
|
-
- Technical opinions (the full Technical Opinions section of profile.md).
|
|
79
|
+
- Technical opinions (the full Technical Opinions section of `profile.md`).
|
|
78
80
|
- Historical decision patterns.
|
|
79
|
-
-
|
|
81
|
+
- A "Load-bearing rules" or "Active projects" or "In-flight work" block: the constraints doc and the `boot-context` hook cover those; a baked copy here goes stale within hours.
|
|
82
|
+
- Communication-style examples that are not load-bearing.
|
|
80
83
|
- Anything the PA can look up at the moment it needs it (this is what the pointers are for).
|
|
81
|
-
- A baked snapshot of project state — the Live-state section's pointer covers that.
|
|
82
84
|
|
|
83
|
-
The test for "should this be in the slice": *if this is wrong or missing, will the PA make a costly mistake in its first 5 messages?* If yes, include it. If no, leave it out.
|
|
85
|
+
The test for "should this be in the slice": *if this is wrong or missing, will the PA make a costly mistake in its first 5 messages, AND is it something the constraints doc plus the boot-context hook cannot cover?* If yes to both, include it. If no, leave it out.
|
|
84
86
|
|
|
85
87
|
## Now produce the slice
|
|
86
88
|
|
|
87
|
-
The inputs follow below. Read them, then produce the slice. Output ONLY the slice markdown
|
|
89
|
+
The inputs follow below. Read them, then produce the slice. Output ONLY the slice markdown: no preamble, no postscript, no explanation. Your entire response is what gets written to disk.
|
|
88
90
|
|
|
89
91
|
---
|
|
90
92
|
|
|
@@ -24,15 +24,114 @@ import { buildSlicePrompt, claudeArgs } from './prompt.mjs';
|
|
|
24
24
|
const REQUIRED_HEADERS = [
|
|
25
25
|
'## Operator basics',
|
|
26
26
|
'## Mission framing',
|
|
27
|
-
'## Active projects',
|
|
28
27
|
'## Active relationships',
|
|
28
|
+
'## Operational constraints',
|
|
29
29
|
'## Tools at your disposal',
|
|
30
|
-
'## Load-bearing rules right now',
|
|
31
30
|
'## Live state',
|
|
32
31
|
];
|
|
33
32
|
|
|
34
33
|
const SIZE_BUDGET_BYTES = 2048;
|
|
35
34
|
|
|
35
|
+
// Post-generate lint (w-2026-09-04-dave-003). Runs after the required-header
|
|
36
|
+
// structural check and before the file write. Refuses to write on any hard
|
|
37
|
+
// failure so a defective regeneration cannot clobber the hand-rewritten slice
|
|
38
|
+
// in place (the safe fallback). `--no-lint` bypasses.
|
|
39
|
+
//
|
|
40
|
+
// Checks are per-template. The dave-boot slice contract (2026-09-03 rewrite,
|
|
41
|
+
// w-2026-07-15-012) requires:
|
|
42
|
+
// 1. Names the platform package `@stravica-ai/digital-operator-pa` (the
|
|
43
|
+
// slice must not describe the platform without naming it).
|
|
44
|
+
// 2. Points at the operational-constraints doc rather than restating rules
|
|
45
|
+
// inline (constraints live in exactly one place).
|
|
46
|
+
// 3. Carries NO "## Active projects" or "## In-flight work" block: live
|
|
47
|
+
// boot state is injected by the boot-context hook, not baked in.
|
|
48
|
+
// 4. Stays inside the byte budget. Budget is soft (SIZE_BUDGET_BYTES,
|
|
49
|
+
// 2048) with a hard ceiling that refuses the write on defective
|
|
50
|
+
// regenerations. The hand-rewritten reference is 2888 B; the current
|
|
51
|
+
// pre-rewrite nightly is 3291 B; the ceiling sits between them at
|
|
52
|
+
// 3000 B so the reference passes and a wildly-over-budget slice is
|
|
53
|
+
// refused rather than overwriting the good slice.
|
|
54
|
+
const SIZE_LINT_HARD_BYTES = 3000;
|
|
55
|
+
const DAVE_BOOT_LINT_CHECKS = [
|
|
56
|
+
{
|
|
57
|
+
id: 'package-name',
|
|
58
|
+
description: 'names the platform package @stravica-ai/digital-operator-pa',
|
|
59
|
+
check: (text) => text.includes('@stravica-ai/digital-operator-pa'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'constraints-pointer',
|
|
63
|
+
description:
|
|
64
|
+
'points at operator/knowledge/operator-interface/operational-constraints.md',
|
|
65
|
+
check: (text) =>
|
|
66
|
+
text.includes(
|
|
67
|
+
'operator/knowledge/operator-interface/operational-constraints.md',
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'no-active-projects-block',
|
|
72
|
+
description:
|
|
73
|
+
'carries no "## Active projects" or "## In-flight work" block (boot-context injects live state)',
|
|
74
|
+
check: (text) =>
|
|
75
|
+
!/^##\s+(Active projects|In-flight work)\b/im.test(text),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'size-hard-ceiling',
|
|
79
|
+
description: `size within hard ceiling of ${SIZE_LINT_HARD_BYTES} B (soft budget ${SIZE_BUDGET_BYTES} B)`,
|
|
80
|
+
check: (text) => Buffer.byteLength(text, 'utf8') <= SIZE_LINT_HARD_BYTES,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const LINT_CHECKS_BY_TEMPLATE = {
|
|
85
|
+
'dave-boot': DAVE_BOOT_LINT_CHECKS,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Return the lint checks for a template name. Custom template paths (anything
|
|
90
|
+
* that does not resolve to a bundled name) get an empty check-set so
|
|
91
|
+
* `--template <path>` is opt-in and never breaks a caller that ships its
|
|
92
|
+
* own contract.
|
|
93
|
+
*
|
|
94
|
+
* The name is the caller's `template` value stripped of any extension: for a
|
|
95
|
+
* bundled name that is the name itself ("dave-boot"); for a custom path we
|
|
96
|
+
* do not attempt to match. This is deliberate: template-specific lint checks
|
|
97
|
+
* are baked into this file because they encode a slice contract that only
|
|
98
|
+
* this repo knows.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} template
|
|
101
|
+
* @returns {Array<{id: string, description: string, check: (t: string) => boolean}>}
|
|
102
|
+
*/
|
|
103
|
+
export function lintChecksFor(template) {
|
|
104
|
+
if (typeof template !== 'string') return [];
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(LINT_CHECKS_BY_TEMPLATE, template)) {
|
|
106
|
+
return LINT_CHECKS_BY_TEMPLATE[template];
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run the template-specific post-generate lint. Returns { ok, failures }
|
|
113
|
+
* where `failures` is a list of `{ id, description }` for every failing
|
|
114
|
+
* check. An empty check-set (custom template) is always ok.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} sliceText
|
|
117
|
+
* @param {string} template
|
|
118
|
+
* @returns {{ ok: boolean, failures: Array<{id: string, description: string}> }}
|
|
119
|
+
*/
|
|
120
|
+
export function lintSlice(sliceText, template) {
|
|
121
|
+
const checks = lintChecksFor(template);
|
|
122
|
+
const failures = [];
|
|
123
|
+
for (const c of checks) {
|
|
124
|
+
let passed = false;
|
|
125
|
+
try {
|
|
126
|
+
passed = c.check(sliceText) === true;
|
|
127
|
+
} catch {
|
|
128
|
+
passed = false;
|
|
129
|
+
}
|
|
130
|
+
if (!passed) failures.push({ id: c.id, description: c.description });
|
|
131
|
+
}
|
|
132
|
+
return { ok: failures.length === 0, failures };
|
|
133
|
+
}
|
|
134
|
+
|
|
36
135
|
function opError(msg, code = 2) {
|
|
37
136
|
const err = new Error(msg);
|
|
38
137
|
err.sliceExitCode = code;
|
|
@@ -51,6 +150,9 @@ function log(line, { stderr = process.stderr } = {}) {
|
|
|
51
150
|
* noop-dry-run — dry-run path; no claude call, no write.
|
|
52
151
|
* fail-usage — caller-side input error (missing arg, bad path).
|
|
53
152
|
* fail-validation — claude returned content missing required headers.
|
|
153
|
+
* fail-lint : post-generate lint refused the write (a defective
|
|
154
|
+
* regeneration was blocked from clobbering the
|
|
155
|
+
* good slice in place). w-2026-09-04-dave-003.
|
|
54
156
|
* fail-claude — claude exited non-zero (transport failure).
|
|
55
157
|
* fail-prereq — input loading failed (missing profile / observations / template).
|
|
56
158
|
* fail-write — writing the slice output file failed.
|
|
@@ -132,6 +234,11 @@ function sessionName(now) {
|
|
|
132
234
|
* @param {string} opts.outputPath Where to write the slice.
|
|
133
235
|
* @param {boolean} [opts.dryRun=false] If true, build the prompt and report,
|
|
134
236
|
* but skip the claude call and file write.
|
|
237
|
+
* @param {boolean} [opts.noLint=false] Bypass the post-generate lint. Written
|
|
238
|
+
* as an escape hatch when the caller
|
|
239
|
+
* knows better than the checks; the
|
|
240
|
+
* scheduled nightly path leaves it off
|
|
241
|
+
* so the lint guards the live slice.
|
|
135
242
|
* @param {object} opts.runner Shared runner (spawn/now/ts/tsIso).
|
|
136
243
|
* @param {object} [opts.stderr] Capture stream for tests.
|
|
137
244
|
* @returns {Promise<number>} Exit code.
|
|
@@ -144,6 +251,7 @@ export async function runSlice({
|
|
|
144
251
|
outputPath,
|
|
145
252
|
runsLog,
|
|
146
253
|
dryRun = false,
|
|
254
|
+
noLint = false,
|
|
147
255
|
runner,
|
|
148
256
|
stderr = process.stderr,
|
|
149
257
|
} = {}) {
|
|
@@ -286,11 +394,41 @@ export async function runSlice({
|
|
|
286
394
|
const bytes = Buffer.byteLength(sliceText, 'utf8');
|
|
287
395
|
if (bytes > SIZE_BUDGET_BYTES) {
|
|
288
396
|
log(
|
|
289
|
-
`WARN: slice is ${bytes}B, exceeds soft budget of ${SIZE_BUDGET_BYTES}B
|
|
397
|
+
`WARN: slice is ${bytes}B, exceeds soft budget of ${SIZE_BUDGET_BYTES}B`,
|
|
290
398
|
{ stderr },
|
|
291
399
|
);
|
|
292
400
|
}
|
|
293
401
|
|
|
402
|
+
// Step 4b: post-generate lint (w-2026-09-04-dave-003).
|
|
403
|
+
//
|
|
404
|
+
// Template-specific checks that catch the "regeneration produces a
|
|
405
|
+
// slice that structurally validates but violates the slice contract"
|
|
406
|
+
// failure mode. On failure the write is refused so the good slice
|
|
407
|
+
// stays in place; the caller can bypass with --no-lint when the
|
|
408
|
+
// checks are wrong for the current contract.
|
|
409
|
+
const lintResult = lintSlice(sliceText, template);
|
|
410
|
+
if (!lintResult.ok) {
|
|
411
|
+
const summary = lintResult.failures
|
|
412
|
+
.map((f) => `${f.id} (${f.description})`)
|
|
413
|
+
.join('; ');
|
|
414
|
+
if (noLint) {
|
|
415
|
+
log(`lint failed but --no-lint set, writing anyway: ${summary}`, {
|
|
416
|
+
stderr,
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
log(
|
|
420
|
+
`lint refused write: ${lintResult.failures.length} check(s) failed: ${summary}`,
|
|
421
|
+
{ stderr },
|
|
422
|
+
);
|
|
423
|
+
await emitAudit({
|
|
424
|
+
status: 'fail-lint',
|
|
425
|
+
exitCode: 2,
|
|
426
|
+
reason: `lint failed: ${summary}`,
|
|
427
|
+
});
|
|
428
|
+
return 2;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
294
432
|
// Step 5 — write.
|
|
295
433
|
try {
|
|
296
434
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
@@ -331,4 +469,4 @@ export async function runSlice({
|
|
|
331
469
|
}
|
|
332
470
|
}
|
|
333
471
|
|
|
334
|
-
export { REQUIRED_HEADERS, SIZE_BUDGET_BYTES };
|
|
472
|
+
export { REQUIRED_HEADERS, SIZE_BUDGET_BYTES, SIZE_LINT_HARD_BYTES };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/engineering-pa-library-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local `library` CLI for the remote-library REMOTE-mode write path (spec §3.3): put | flush | get | search | status. SIMPLE mode installs refuse every verb with a clean not-applicable message.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/cli.mjs",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// error surface is the resolveLibraryConfig throw path.
|
|
12
12
|
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
|
|
15
18
|
import { loadLibraryConfig, LibraryConfigError } from './config.mjs';
|
|
16
19
|
import { runPut } from './verbs/put.mjs';
|
|
@@ -20,6 +23,26 @@ import { runSearch } from './verbs/search.mjs';
|
|
|
20
23
|
import { runStatus } from './verbs/status.mjs';
|
|
21
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
22
25
|
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Own-package version, read from the shipped package.json alongside this
|
|
30
|
+
* source (both in-tree and bundled under
|
|
31
|
+
* dist/engineering-pa-library-cli/). Read at invocation, not import: a
|
|
32
|
+
* missing/malformed package.json degrades to 'unknown' rather than
|
|
33
|
+
* crashing --version, which is the whole point of the vault-free help
|
|
34
|
+
* seam (w-2026-09-02-dave-015 part 2).
|
|
35
|
+
*/
|
|
36
|
+
function readOwnVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(join(__dirname, '..', 'package.json'), 'utf-8');
|
|
39
|
+
const pkg = JSON.parse(raw);
|
|
40
|
+
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
|
|
41
|
+
} catch {
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
const USAGE = `library — REMOTE-mode client for the remote-library service.
|
|
24
47
|
|
|
25
48
|
Usage:
|
|
@@ -30,9 +53,15 @@ Usage:
|
|
|
30
53
|
library status [--json]
|
|
31
54
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
32
55
|
library -h | --help
|
|
56
|
+
library -v | --version
|
|
33
57
|
|
|
34
58
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
35
|
-
Verbs are only active in REMOTE mode. SIMPLE mode
|
|
59
|
+
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
60
|
+
one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
|
|
61
|
+
with a clean not-applicable message.
|
|
62
|
+
|
|
63
|
+
--help and --version never touch config or the vault, so they always work
|
|
64
|
+
from a directory with no operator config and no vault installed.
|
|
36
65
|
`;
|
|
37
66
|
|
|
38
67
|
/**
|
|
@@ -44,10 +73,19 @@ export async function main(argv, seams = {}) {
|
|
|
44
73
|
const log = seams.log ?? ((...m) => process.stdout.write(m.join(' ') + '\n'));
|
|
45
74
|
const err = seams.err ?? ((...m) => process.stderr.write(m.join(' ') + '\n'));
|
|
46
75
|
|
|
76
|
+
// --help and --version are answered BEFORE any config or vault
|
|
77
|
+
// resolution runs. Both must work from a directory with no operator
|
|
78
|
+
// config and no vault installed at all: the vault-free help/version
|
|
79
|
+
// seam (w-2026-09-02-dave-015 part 2). Everything after this line is
|
|
80
|
+
// allowed to touch preferences.json; nothing before it is.
|
|
47
81
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
48
82
|
log(USAGE);
|
|
49
83
|
return 0;
|
|
50
84
|
}
|
|
85
|
+
if (argv[0] === '-v' || argv[0] === '--version') {
|
|
86
|
+
log(readOwnVersion());
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
51
89
|
|
|
52
90
|
const verb = argv[0];
|
|
53
91
|
const rest = argv.slice(1);
|
|
@@ -64,6 +102,20 @@ export async function main(argv, seams = {}) {
|
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
if (config.mode === 'simple') {
|
|
105
|
+
// `library status` is the one verb useful in SIMPLE mode too: a plain
|
|
106
|
+
// one-line mode summary that tells the operator the CLI is installed
|
|
107
|
+
// and the remote path is intentionally not wired. No vault touch,
|
|
108
|
+
// no network call, exit 0. The "must work with no vault at all"
|
|
109
|
+
// requirement from w-2026-09-02-dave-015 part 2.
|
|
110
|
+
if (verb === 'status') {
|
|
111
|
+
const wantsJson = rest.includes('--json');
|
|
112
|
+
if (wantsJson) {
|
|
113
|
+
log(JSON.stringify({ mode: 'simple' }, null, 2));
|
|
114
|
+
} else {
|
|
115
|
+
log('mode: simple (library CLI installed; no remote endpoint configured)');
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
67
119
|
err(
|
|
68
120
|
`[library] not applicable in simple mode (library.mode="simple"). ` +
|
|
69
121
|
`The library CLI is a REMOTE-mode client only (spec §3.3). ` +
|
|
@@ -13,12 +13,30 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
15
|
import { hostname } from 'node:os';
|
|
16
|
-
import { isAbsolute, normalize, relative, sep } from 'node:path';
|
|
16
|
+
import { basename, extname, isAbsolute, normalize, relative, sep } from 'node:path';
|
|
17
17
|
|
|
18
18
|
import { EVENT_KINDS, canonicalJson } from '@stravica-ai/remote-library';
|
|
19
19
|
|
|
20
20
|
export const KNOWN_KINDS = new Set(EVENT_KINDS);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Kinds whose ingest schema accepts an optional `payload.source` routing
|
|
24
|
+
* hint (spec §2.3 payloadSource dimension). When the operator asserts
|
|
25
|
+
* an explicit `--tier <repo>` on `library put`, the CLI stamps
|
|
26
|
+
* `payload.source = <repo>` on these four kinds so the routing engine's
|
|
27
|
+
* `payloadSource` match can direct the event to the correct tier repo.
|
|
28
|
+
*
|
|
29
|
+
* Kept in lockstep with the remote-library package's per-kind payload
|
|
30
|
+
* schemas: adding a source-accepting kind here without the matching
|
|
31
|
+
* schema field would 422 at ingest.
|
|
32
|
+
*/
|
|
33
|
+
export const KINDS_ACCEPTING_SOURCE = new Set([
|
|
34
|
+
'knowledge.doc.upsert',
|
|
35
|
+
'knowledge.doc.delete',
|
|
36
|
+
'profile.upsert',
|
|
37
|
+
'profile.slice.upsert',
|
|
38
|
+
]);
|
|
39
|
+
|
|
22
40
|
/**
|
|
23
41
|
* Compute the sha256 hash of the canonical JSON form of a payload.
|
|
24
42
|
* The `sha256:` prefix matches the server's contentSha convention.
|
|
@@ -118,9 +136,13 @@ export function guessKindFromPath(sourcePath) {
|
|
|
118
136
|
* @param {string} [args.tier] default "auto" sentinel (spec §2.3)
|
|
119
137
|
* @param {'staging'|'flush'|'role-output'|'manual'|'synthesis'} [args.sourceKind]
|
|
120
138
|
* default "manual"; `library flush` passes "flush"
|
|
121
|
-
* @param {string} [args.source] optional routing hint;
|
|
122
|
-
*
|
|
123
|
-
*
|
|
139
|
+
* @param {string} [args.source] optional routing hint; encoded into
|
|
140
|
+
* the payload for any kind whose ingest schema accepts `source`
|
|
141
|
+
* (KINDS_ACCEPTING_SOURCE: knowledge.doc.upsert, knowledge.doc.delete,
|
|
142
|
+
* profile.upsert, profile.slice.upsert; spec §3.1 shape). When
|
|
143
|
+
* `--tier` is asserted explicitly (not the `auto` sentinel), the tier
|
|
144
|
+
* value is used as the source stamp for these kinds automatically, so
|
|
145
|
+
* callers rarely pass this directly.
|
|
124
146
|
* @param {() => Date} [args.clock] test seam
|
|
125
147
|
* @returns {object} EventInput ready for POST /events
|
|
126
148
|
*/
|
|
@@ -170,26 +192,75 @@ export function buildEnvelope({
|
|
|
170
192
|
|
|
171
193
|
const now = clock();
|
|
172
194
|
// Per-kind payload construction (spec §3.1 + `remote-library` payload
|
|
173
|
-
// schema).
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// `
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
// schema).
|
|
196
|
+
//
|
|
197
|
+
// The delete branch omits `content` (ingest 422 otherwise; Fix Wave E
|
|
198
|
+
// w-2026-08-24-dave-029). profile.slice.upsert takes `{slice, content}`
|
|
199
|
+
// (slice is a single filename component, derived from the source
|
|
200
|
+
// path's basename minus its extension; the schema pattern refuses
|
|
201
|
+
// path separators). Every other content-bearing kind ships as
|
|
202
|
+
// `{path, content}` byte-identical to the pre-Wave-E encoding.
|
|
203
|
+
//
|
|
204
|
+
// Source stamping (w-2026-09-03-dave-010): when the caller asserts an
|
|
205
|
+
// explicit tier and the kind's ingest schema accepts a routing hint,
|
|
206
|
+
// `payload.source = <tier>` is attached. The routing engine's
|
|
207
|
+
// `payloadSource` match then directs the event to the matching tier
|
|
208
|
+
// rule (the operator-barry-profile rules from rules v23 depend on
|
|
209
|
+
// this signal). Kinds not in KINDS_ACCEPTING_SOURCE keep their
|
|
210
|
+
// pre-cure payload shape byte-identical; the envelope regression
|
|
211
|
+
// tests pin that determinism against a frozen contentSha.
|
|
212
|
+
const shouldStampFromTier =
|
|
213
|
+
tier !== 'auto' && KINDS_ACCEPTING_SOURCE.has(resolvedKind);
|
|
214
|
+
const effectiveSource = shouldStampFromTier ? tier : source;
|
|
215
|
+
const stampSource =
|
|
216
|
+
effectiveSource !== undefined &&
|
|
217
|
+
KINDS_ACCEPTING_SOURCE.has(resolvedKind);
|
|
218
|
+
let payload;
|
|
219
|
+
if (resolvedKind === 'knowledge.doc.delete') {
|
|
220
|
+
payload = {
|
|
221
|
+
path: relPath ?? null,
|
|
222
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
223
|
+
};
|
|
224
|
+
} else if (resolvedKind === 'profile.slice.upsert') {
|
|
225
|
+
// Slice is the source path's basename without its extension. The
|
|
226
|
+
// ingest schema's pattern (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) refuses
|
|
227
|
+
// path separators, empty strings, and leading dot/hyphen. Callers
|
|
228
|
+
// passing a source path outside the slice tree get an actionable
|
|
229
|
+
// client-side refusal here rather than a 422 from the wire.
|
|
230
|
+
const filename = basename(relPath ?? sourcePath ?? '');
|
|
231
|
+
const slice = filename.endsWith('.md')
|
|
232
|
+
? filename.slice(0, -3)
|
|
233
|
+
: extname(filename)
|
|
234
|
+
? filename.slice(0, -extname(filename).length)
|
|
235
|
+
: filename;
|
|
236
|
+
if (!slice) {
|
|
237
|
+
const err = new Error(
|
|
238
|
+
`profile.slice.upsert requires a non-empty slice name (derived from ` +
|
|
239
|
+
`basename of sourcePath minus extension); sourcePath=${JSON.stringify(sourcePath)}`,
|
|
240
|
+
);
|
|
241
|
+
err.exitCode = 1;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
payload = {
|
|
245
|
+
slice,
|
|
246
|
+
content,
|
|
247
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
248
|
+
};
|
|
249
|
+
} else if (KINDS_ACCEPTING_SOURCE.has(resolvedKind)) {
|
|
250
|
+
// knowledge.doc.upsert, profile.upsert.
|
|
251
|
+
payload = {
|
|
252
|
+
path: relPath ?? null,
|
|
253
|
+
content,
|
|
254
|
+
...(stampSource ? { source: effectiveSource } : {}),
|
|
255
|
+
};
|
|
256
|
+
} else {
|
|
257
|
+
// Every other kind keeps the pre-cure {path, content} shape
|
|
258
|
+
// byte-identical.
|
|
259
|
+
payload = {
|
|
260
|
+
path: relPath ?? null,
|
|
261
|
+
content,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
193
264
|
|
|
194
265
|
const envelope = {
|
|
195
266
|
clientEventId: randomUUID(),
|
package/package.json
CHANGED