wendkeep 0.86.0 → 0.88.0
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/.githooks/commit-msg +16 -0
- package/.githooks/prepare-commit-msg +16 -0
- package/CHANGELOG.md +32 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/en/commands/commit.md +159 -0
- package/docs/en/commands/observer-security.md +154 -0
- package/docs/en/commands/observer.md +30 -12
- package/docs/pt-BR/commands/commit.md +159 -0
- package/docs/pt-BR/commands/observer-security.md +154 -0
- package/docs/pt-BR/commands/observer.md +30 -12
- package/hooks/observer-publish.mjs +3 -1
- package/package.json +6 -2
- package/packages/cli/src/index.mjs +11 -1
- package/packages/commit/package.json +6 -0
- package/packages/commit/src/cli.mjs +89 -0
- package/packages/commit/src/commit-input.mjs +181 -0
- package/packages/commit/src/commit-message.mjs +51 -0
- package/packages/commit/src/commit-policy.mjs +144 -0
- package/packages/commit/src/git-runtime.mjs +428 -0
- package/packages/commit/src/index.mjs +28 -0
- package/packages/commit/src/proof-validation.mjs +443 -0
- package/packages/mcp/src/executor.mjs +35 -2
- package/packages/observer/package.json +16 -0
- package/packages/observer/src/audit.mjs +1 -0
- package/packages/observer/src/authz.mjs +38 -0
- package/packages/observer/src/encryption.mjs +75 -0
- package/packages/observer/src/index.mjs +7 -0
- package/packages/observer/src/policy.mjs +305 -0
- package/packages/observer/src/purge.mjs +100 -0
- package/packages/observer/src/redaction.mjs +54 -0
- package/packages/observer/src/retention.mjs +39 -0
- package/packages/observer/src/token-registry.mjs +122 -0
- package/schema/commit-message-v1.schema.json +75 -0
- package/schema/observer/006-observer-security.sql +64 -0
- package/schema/observer-policy-v1.schema.json +63 -0
- package/schema/sync-event-v1.schema.json +10 -0
- package/scripts/validate-commit-range.mjs +244 -0
- package/src/doctor.mjs +7 -0
- package/src/git-commit-hooks.mjs +112 -0
- package/src/init.mjs +13 -0
- package/src/observer-auth.mjs +8 -0
- package/src/observer-privacy.mjs +7 -3
- package/src/observer-publish.mjs +31 -0
- package/src/observer-server.mjs +179 -20
- package/src/observer-sql-migrate.mjs +5 -2
- package/src/observer-sql-publish.mjs +114 -39
- package/src/observer-sql-store.mjs +299 -45
- package/src/observer-transcript-store.mjs +23 -8
- package/src/observer.mjs +145 -12
- package/src/skills-seed.mjs +79 -0
- package/src/sync-protocol.mjs +20 -0
- package/web/observer/app.mjs +107 -31
- package/web/observer/index.html +7 -0
- package/web/observer/styles.css +5 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://wendkeep.dev/schema/commit-message-v1.schema.json",
|
|
4
|
+
"title": "WendKeep evidence-based commit input",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schema_version", "subject", "capability", "authority", "evidence"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "const": 1 },
|
|
10
|
+
"subject": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"required": ["type", "summary"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"type": { "enum": ["feat", "fix", "refactor", "perf"] },
|
|
16
|
+
"scope": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$" },
|
|
17
|
+
"summary": { "type": "string", "minLength": 1, "maxLength": 120 }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"capability": { "type": "string", "minLength": 1, "maxLength": 500 },
|
|
21
|
+
"authority": {
|
|
22
|
+
"oneOf": [
|
|
23
|
+
{
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["kind", "adr", "ref"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"kind": { "const": "adr" },
|
|
29
|
+
"adr": { "type": "string", "pattern": "^ADR-[0-9]{4,}$" },
|
|
30
|
+
"ref": { "type": "string", "minLength": 1 },
|
|
31
|
+
"issue": { "type": "string", "pattern": "^#[0-9]+$" }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"required": ["kind", "issue", "design"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"kind": { "const": "native" },
|
|
40
|
+
"issue": { "type": "string", "pattern": "^#[0-9]+$" },
|
|
41
|
+
"design": { "type": "string", "pattern": "^(docs/superpowers/specs|plans)/(?!\\.{1,2}(?:/|$))(?!.*//)(?!.*\\/\\.{1,2}(?:\\/|$))[a-zA-Z0-9._/-]+\\.md$" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"staged_diff": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"required": ["sha256", "files"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"sha256": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" },
|
|
52
|
+
"files": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"evidence": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"minItems": 1,
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"required": ["kind", "ref"],
|
|
62
|
+
"properties": {
|
|
63
|
+
"kind": { "enum": ["adr", "design", "evidence", "receipt", "spec", "task", "verdict"] },
|
|
64
|
+
"ref": { "type": "string", "minLength": 1 }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"limits": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
|
69
|
+
"identity": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"properties": { "agent": { "type": "string" } }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- wendkeep:structural
|
|
2
|
+
-- Security lifecycle: hash-only credentials, access audit, retention, purge receipts and encrypted columns.
|
|
3
|
+
|
|
4
|
+
ALTER TABLE llm_calls ADD COLUMN prompt_envelope TEXT NOT NULL DEFAULT '';
|
|
5
|
+
ALTER TABLE llm_calls ADD COLUMN response_envelope TEXT NOT NULL DEFAULT '';
|
|
6
|
+
ALTER TABLE llm_calls ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
|
|
7
|
+
ALTER TABLE documents ADD COLUMN content_envelope TEXT NOT NULL DEFAULT '';
|
|
8
|
+
ALTER TABLE documents ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
|
|
9
|
+
ALTER TABLE transcripts ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
|
|
10
|
+
ALTER TABLE project_snapshots ADD COLUMN snapshot_envelope TEXT NOT NULL DEFAULT '';
|
|
11
|
+
|
|
12
|
+
CREATE TABLE IF NOT EXISTS observer_tokens (
|
|
13
|
+
token_id TEXT PRIMARY KEY,
|
|
14
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
15
|
+
role TEXT NOT NULL CHECK(role IN ('viewer', 'auditor', 'publisher', 'admin')),
|
|
16
|
+
project_ids_json TEXT NOT NULL,
|
|
17
|
+
scopes_json TEXT NOT NULL,
|
|
18
|
+
created_at TEXT NOT NULL,
|
|
19
|
+
expires_at TEXT NOT NULL,
|
|
20
|
+
revoked_at TEXT,
|
|
21
|
+
rotated_from TEXT REFERENCES observer_tokens(token_id) ON DELETE SET NULL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_observer_tokens_hash_active ON observer_tokens(token_hash, revoked_at, expires_at);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS observer_access_audit (
|
|
27
|
+
audit_id TEXT PRIMARY KEY,
|
|
28
|
+
project_id TEXT NOT NULL,
|
|
29
|
+
token_id TEXT REFERENCES observer_tokens(token_id) ON DELETE SET NULL,
|
|
30
|
+
capability TEXT NOT NULL,
|
|
31
|
+
outcome TEXT NOT NULL,
|
|
32
|
+
occurred_at TEXT NOT NULL,
|
|
33
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_observer_audit_project_time ON observer_access_audit(project_id, occurred_at);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS observer_retention_policies (
|
|
39
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
40
|
+
policy_json TEXT NOT NULL,
|
|
41
|
+
updated_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS observer_purge_receipts (
|
|
45
|
+
receipt_id TEXT PRIMARY KEY,
|
|
46
|
+
project_id TEXT NOT NULL,
|
|
47
|
+
request_hash TEXT NOT NULL,
|
|
48
|
+
cutoff_at TEXT NOT NULL,
|
|
49
|
+
classes_json TEXT NOT NULL,
|
|
50
|
+
counts_json TEXT NOT NULL,
|
|
51
|
+
purged_at TEXT NOT NULL,
|
|
52
|
+
receipt_hash TEXT NOT NULL UNIQUE,
|
|
53
|
+
receipt_json TEXT NOT NULL
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_observer_purge_project_time ON observer_purge_receipts(project_id, purged_at);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_observer_purge_request ON observer_purge_receipts(project_id, request_hash, purged_at);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS observer_security_backfill (
|
|
60
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
61
|
+
status TEXT NOT NULL,
|
|
62
|
+
protected_rows INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
updated_at TEXT NOT NULL
|
|
64
|
+
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://wendkeep.dev/schema/observer-policy-v1.schema.json",
|
|
4
|
+
"title": "WendKeep Observer policy v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"document_capture": { "enum": ["none", "metadata", "selected", "full"] },
|
|
9
|
+
"transcript_capture": { "enum": ["none", "metadata", "messages", "full"] },
|
|
10
|
+
"prompt_capture": { "enum": ["none", "redacted", "full"] },
|
|
11
|
+
"response_capture": { "enum": ["none", "redacted", "full"] },
|
|
12
|
+
"usage_capture": { "enum": ["none", "aggregate", "calls"] },
|
|
13
|
+
"require_loopback_auth": { "type": "boolean" },
|
|
14
|
+
"encryption_required": { "type": "boolean" },
|
|
15
|
+
"rules": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"required": ["data_class", "capture"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"project_id": { "type": "string", "minLength": 1, "maxLength": 121 },
|
|
23
|
+
"data_class": { "enum": ["document", "transcript", "prompt", "response", "usage"] },
|
|
24
|
+
"path": { "type": "string", "maxLength": 2048 },
|
|
25
|
+
"entity_type": { "type": "string", "maxLength": 160 },
|
|
26
|
+
"capture": { "enum": ["none", "metadata", "selected", "full", "messages", "redacted", "aggregate", "calls"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"redaction": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"rules": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"required": ["pattern"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"pattern": { "type": "string", "minLength": 1, "maxLength": 512 },
|
|
42
|
+
"replacement": { "type": "string", "maxLength": 160 }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"retention": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"properties": {
|
|
52
|
+
"document": { "type": "integer", "minimum": 0 },
|
|
53
|
+
"transcript": { "type": "integer", "minimum": 0 },
|
|
54
|
+
"prompt": { "type": "integer", "minimum": 0 },
|
|
55
|
+
"response": { "type": "integer", "minimum": 0 },
|
|
56
|
+
"usage": { "type": "integer", "minimum": 0 },
|
|
57
|
+
"documents": { "type": "integer", "minimum": 0 },
|
|
58
|
+
"calls": { "type": "integer", "minimum": 0 },
|
|
59
|
+
"transcripts": { "type": "integer", "minimum": 0 }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
"observed_at": { "type": "string", "format": "date-time" },
|
|
21
21
|
"operation": { "enum": ["put", "tombstone", "resolve"] },
|
|
22
22
|
"privacy": { "enum": ["shared", "private"] },
|
|
23
|
+
"policy_ref": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["policy_id", "version", "hash"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"policy_id": { "type": "string", "minLength": 1, "maxLength": 160 },
|
|
29
|
+
"version": { "type": "integer", "minimum": 1 },
|
|
30
|
+
"hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
23
33
|
"payload": {}
|
|
24
34
|
}
|
|
25
35
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
messageEvidence, messageScope, messageTasks, messageTests, nativeDesignReference, validateCommitMessage,
|
|
10
|
+
} from '../packages/commit/src/index.mjs';
|
|
11
|
+
import {
|
|
12
|
+
collectCommitSensorProof,
|
|
13
|
+
commitTaskSensorIds,
|
|
14
|
+
parseSignedEvidenceRef,
|
|
15
|
+
validateCommitProofSet,
|
|
16
|
+
} from '../packages/commit/src/proof-validation.mjs';
|
|
17
|
+
|
|
18
|
+
function option(argv, name) {
|
|
19
|
+
const index = argv.indexOf(name);
|
|
20
|
+
if (index >= 0) return argv[index + 1] || '';
|
|
21
|
+
return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function git(args, { binary = false, allowFailure = false } = {}) {
|
|
25
|
+
const result = spawnSync('git', args, {
|
|
26
|
+
encoding: binary ? null : 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
if (!allowFailure && result.status !== 0) {
|
|
29
|
+
process.stderr.write((binary ? result.stderr?.toString('utf8') : result.stderr) || `git ${args.join(' ')} failed\n`);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const text = (args, options) => String(git(args, options).stdout || '');
|
|
35
|
+
const objectExists = (specifier) => git(['cat-file', '-e', specifier], { allowFailure: true }).status === 0;
|
|
36
|
+
const parents = (sha) => text(['show', '-s', '--format=%P', sha]).trim().split(/\s+/).filter(Boolean);
|
|
37
|
+
|
|
38
|
+
function commitDiff(sha) {
|
|
39
|
+
const parent = parents(sha)[0];
|
|
40
|
+
const args = parent
|
|
41
|
+
? ['diff', '--binary', '--no-ext-diff', '--no-color', parent, sha]
|
|
42
|
+
: ['show', '--format=', '--binary', '--no-ext-diff', '--no-color', sha];
|
|
43
|
+
return git(args, { binary: true }).stdout;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function changedFiles(sha) {
|
|
47
|
+
const parent = parents(sha)[0];
|
|
48
|
+
const args = parent
|
|
49
|
+
? ['diff', '--name-only', '-z', '--no-renames', parent, sha]
|
|
50
|
+
: ['diff-tree', '--root', '--no-commit-id', '--name-only', '-z', '-r', sha];
|
|
51
|
+
return text(args).split('\0').filter(Boolean).map((path) => path.replaceAll('\\', '/'))
|
|
52
|
+
.sort((left, right) => left.localeCompare(right, 'en'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scopeFor(sha) {
|
|
56
|
+
return { sha256: createHash('sha256').update(commitDiff(sha)).digest('hex'), files: changedFiles(sha) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const docsPath = (path) => /^(?:docs\/|README(?:\.en)?\.md$|[^/]+\.md$)/.test(path);
|
|
60
|
+
const testPath = (path) => /^(?:tests?\/|fixtures?\/)/.test(path) || /(?:^|\/)__tests__\//.test(path);
|
|
61
|
+
function trivialCommit(subject, files) {
|
|
62
|
+
if (!files.length) return false;
|
|
63
|
+
if (/^docs(?:\([^)]*\))?:/.test(subject)) return files.every(docsPath);
|
|
64
|
+
if (/^test(?:\([^)]*\))?:/.test(subject)) return files.every(testPath);
|
|
65
|
+
if (/^chore(?:\([^)]*\))?:/.test(subject)) return files.every((path) => docsPath(path) || testPath(path));
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeErrors(sha, message) {
|
|
70
|
+
const commitParents = parents(sha);
|
|
71
|
+
if (commitParents.length < 2 || !/^Merge\b/.test(message.split(/\r?\n/, 1)[0])) {
|
|
72
|
+
return ['merge commit must have multiple parents and a canonical Merge subject'];
|
|
73
|
+
}
|
|
74
|
+
const errors = [];
|
|
75
|
+
for (const path of changedFiles(sha)) {
|
|
76
|
+
const mergeObject = text(['rev-parse', `${sha}:${path}`], { allowFailure: true }).trim();
|
|
77
|
+
if (!mergeObject) continue;
|
|
78
|
+
const inherited = commitParents.some((parent) => (
|
|
79
|
+
text(['rev-parse', `${parent}:${path}`], { allowFailure: true }).trim() === mergeObject
|
|
80
|
+
));
|
|
81
|
+
if (!inherited) errors.push(`WENDKEEP_COMMIT_MERGE_RESOLUTION_UNGOVERNED: ${path}`);
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function commitJson(sha, path) {
|
|
87
|
+
try { return JSON.parse(text(['show', `${sha}:${path}`])); } catch { return null; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectSensorsAtCommit(sha, entries) {
|
|
91
|
+
const ids = commitTaskSensorIds(entries);
|
|
92
|
+
if (!ids.length) return null;
|
|
93
|
+
const config = commitJson(sha, 'wendkeep.sensors.json');
|
|
94
|
+
if (!config || !Array.isArray(config.sensors)) {
|
|
95
|
+
throw Object.assign(new Error('versioned wendkeep.sensors.json is required'), {
|
|
96
|
+
code: 'WENDKEEP_COMMIT_SENSOR_CONFIG_MISSING',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const parent = mkdtempSync(join(tmpdir(), 'wendkeep-commit-range-'));
|
|
100
|
+
const checkout = join(parent, 'checkout');
|
|
101
|
+
const added = git(['worktree', 'add', '--detach', '--force', checkout, sha], { allowFailure: true });
|
|
102
|
+
if (added.status !== 0) {
|
|
103
|
+
rmSync(parent, { recursive: true, force: true });
|
|
104
|
+
throw Object.assign(new Error(String(added.stderr || 'temporary commit checkout failed').trim()), {
|
|
105
|
+
code: 'WENDKEEP_COMMIT_CHECKOUT_FAILED',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return collectCommitSensorProof({ sensors: config.sensors, ids, cwd: checkout });
|
|
110
|
+
} finally {
|
|
111
|
+
git(['worktree', 'remove', '--force', checkout], { allowFailure: true });
|
|
112
|
+
const bounded = resolve(parent);
|
|
113
|
+
if (bounded.startsWith(resolve(tmpdir()))) rmSync(bounded, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function evidenceErrors(sha, message, scope) {
|
|
118
|
+
const errors = [];
|
|
119
|
+
const evidence = messageEvidence(message);
|
|
120
|
+
const entries = [];
|
|
121
|
+
for (const item of evidence) {
|
|
122
|
+
if (item.status !== 'verified' || ['evidence', 'receipt', 'verdict'].includes(item.kind)) {
|
|
123
|
+
errors.push('WENDKEEP_COMMIT_REMOTE_PROOF_UNAVAILABLE');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let signed;
|
|
127
|
+
try { signed = parseSignedEvidenceRef(item.ref); }
|
|
128
|
+
catch (error) { errors.push(error.code || error.message); continue; }
|
|
129
|
+
if (!objectExists(`${sha}:${signed.path}`)) {
|
|
130
|
+
errors.push(`WENDKEEP_COMMIT_EVIDENCE_UNVERSIONED: ${signed.path}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
entries.push({ kind: item.kind, path: signed.path, sha256: signed.sha256, content: text(['show', `${sha}:${signed.path}`]) });
|
|
134
|
+
if (item.status !== 'verified') errors.push(`WENDKEEP_COMMIT_EVIDENCE_STATUS_INVALID: ${signed.path}`);
|
|
135
|
+
}
|
|
136
|
+
const adr = message.match(/^ADR:\s*(ADR-\d{4,})$/m)?.[1] || '';
|
|
137
|
+
const design = nativeDesignReference(message);
|
|
138
|
+
const authority = adr
|
|
139
|
+
? { kind: 'adr', adr, ref: entries.find((entry) => entry.kind === 'adr')?.path || '', issue: message.match(/^Refs:\s*(#\d+)$/m)?.[1] || '' }
|
|
140
|
+
: { kind: 'native', issue: message.match(/^Issue:\s*(#\d+)$/m)?.[1] || '', design };
|
|
141
|
+
try {
|
|
142
|
+
const config = commitJson(sha, '.wendkeep.json') || {};
|
|
143
|
+
const executionProof = collectSensorsAtCommit(sha, entries);
|
|
144
|
+
const snapshot = {
|
|
145
|
+
head_sha: parents(sha)[0] || sha,
|
|
146
|
+
index_tree_sha: text(['show', '-s', '--format=%T', sha]).trim(),
|
|
147
|
+
};
|
|
148
|
+
const resolved = validateCommitProofSet({
|
|
149
|
+
entries, authority, stagedHash: scope.sha256,
|
|
150
|
+
context: {
|
|
151
|
+
projectId: config.projectId || '',
|
|
152
|
+
changeSlug: adr ? adr.toLowerCase() : `issue-${authority.issue.slice(1)}`,
|
|
153
|
+
baseSha: snapshot.base_sha,
|
|
154
|
+
headSha: snapshot.head_sha,
|
|
155
|
+
indexTreeSha: snapshot.index_tree_sha,
|
|
156
|
+
worktreeDigest: snapshot.worktree_digest,
|
|
157
|
+
dirty: snapshot.dirty,
|
|
158
|
+
sensorConfigSha256: executionProof?.configSha256,
|
|
159
|
+
executionProof,
|
|
160
|
+
profile: String(config?.harness?.profile || 'OFF').toUpperCase(),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (JSON.stringify(messageTasks(message)) !== JSON.stringify(resolved.tasks)) errors.push('WENDKEEP_COMMIT_TASKS_MISMATCH');
|
|
164
|
+
if (JSON.stringify(messageTests(message)) !== JSON.stringify(resolved.tests)) errors.push('WENDKEEP_COMMIT_TESTS_MISMATCH');
|
|
165
|
+
} catch (error) {
|
|
166
|
+
errors.push(`${error.code || 'WENDKEEP_COMMIT_EVIDENCE_UNVERIFIED'}: ${error.message}`);
|
|
167
|
+
}
|
|
168
|
+
return errors;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function authorityErrors(sha, message) {
|
|
172
|
+
const errors = [];
|
|
173
|
+
const design = nativeDesignReference(message);
|
|
174
|
+
if (!design) return errors;
|
|
175
|
+
if (!objectExists(`${sha}:${design}`)) return [`WENDKEEP_COMMIT_DESIGN_UNVERSIONED: ${design}`];
|
|
176
|
+
const issue = message.match(/^Issue:\s*(#\d+)$/m)?.[1] || '';
|
|
177
|
+
const content = text(['show', `${sha}:${design}`]);
|
|
178
|
+
if (!content.includes(issue)) errors.push('WENDKEEP_COMMIT_NATIVE_ISSUE_UNVERIFIED');
|
|
179
|
+
const config = commitJson(sha, '.wendkeep.json');
|
|
180
|
+
if (String(config?.harness?.profile || '').toUpperCase() !== 'OFF') errors.push('WENDKEEP_COMMIT_NATIVE_PROFILE_REQUIRED');
|
|
181
|
+
const adrPaths = text(['ls-tree', '-r', '--name-only', sha]).split(/\r?\n/)
|
|
182
|
+
.filter((path) => /ADR-\d+.*\.md$/i.test(path));
|
|
183
|
+
if (adrPaths.some((path) => {
|
|
184
|
+
const adr = text(['show', `${sha}:${path}`]);
|
|
185
|
+
return adr.includes(issue) && adr.includes(design.split('/').at(-1));
|
|
186
|
+
})) errors.push('WENDKEEP_COMMIT_CAUSAL_AUTHORITY_EXISTS');
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function configuredPrivacyErrors(sha, message) {
|
|
191
|
+
const config = commitJson(sha, '.wendkeep.json');
|
|
192
|
+
const vault = typeof config?.vault === 'string' ? config.vault.replaceAll('\\', '/').trim() : '';
|
|
193
|
+
if (!vault) return [];
|
|
194
|
+
const name = vault.split('/').filter(Boolean).at(-1) || '';
|
|
195
|
+
const lower = message.toLowerCase();
|
|
196
|
+
return [vault, name].some((marker) => marker.length >= 3 && lower.includes(marker.toLowerCase()))
|
|
197
|
+
? ['WENDKEEP_COMMIT_PRIVATE_PATH: message references the configured project Vault'] : [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const base = option(process.argv.slice(2), '--base') || process.env.WENDKEEP_COMMIT_BASE || '';
|
|
201
|
+
const head = option(process.argv.slice(2), '--head') || process.env.WENDKEEP_COMMIT_HEAD || 'HEAD';
|
|
202
|
+
if (!base) {
|
|
203
|
+
process.stderr.write('WENDKEEP_COMMIT_ARGUMENT: --base is required\n');
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const commits = text(['rev-list', '--reverse', `${base}..${head}`]).trim().split(/\r?\n/).filter(Boolean);
|
|
208
|
+
const failures = [];
|
|
209
|
+
for (const sha of commits) {
|
|
210
|
+
const message = text(['show', '-s', '--format=%B', sha]);
|
|
211
|
+
const subject = message.split(/\r?\n/, 1)[0];
|
|
212
|
+
const commitParents = parents(sha);
|
|
213
|
+
const errors = [...configuredPrivacyErrors(sha, message)];
|
|
214
|
+
if (commitParents.length > 1) {
|
|
215
|
+
errors.push(...mergeErrors(sha, message));
|
|
216
|
+
} else {
|
|
217
|
+
const result = validateCommitMessage(message);
|
|
218
|
+
errors.push(...result.errors);
|
|
219
|
+
if (messageEvidence(message).some((item) => (
|
|
220
|
+
item.status !== 'verified' || ['evidence', 'receipt', 'verdict'].includes(item.kind)
|
|
221
|
+
))) errors.push('WENDKEEP_COMMIT_REMOTE_PROOF_UNAVAILABLE');
|
|
222
|
+
const files = changedFiles(sha);
|
|
223
|
+
if (!result.governed && !trivialCommit(subject, files)) errors.push('WENDKEEP_COMMIT_PRODUCT_CHANGE_UNGOVERNED');
|
|
224
|
+
if (result.governed && result.ok) {
|
|
225
|
+
const expected = scopeFor(sha);
|
|
226
|
+
const observed = messageScope(message);
|
|
227
|
+
if (expected.sha256 !== observed.sha256 || JSON.stringify(expected.files) !== JSON.stringify(observed.files)) {
|
|
228
|
+
errors.push('WENDKEEP_COMMIT_SCOPE_MISMATCH');
|
|
229
|
+
}
|
|
230
|
+
errors.push(...evidenceErrors(sha, message, expected), ...authorityErrors(sha, message));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (errors.length) failures.push({ sha, subject, errors: [...new Set(errors)] });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (failures.length) {
|
|
237
|
+
process.stderr.write(`WENDKEEP_COMMIT_RANGE_INVALID: ${failures.length} invalid commit(s)\n`);
|
|
238
|
+
for (const failure of failures) {
|
|
239
|
+
process.stderr.write(`${failure.sha.slice(0, 12)} ${failure.subject}\n`);
|
|
240
|
+
for (const error of failure.errors) process.stderr.write(` - ${error}\n`);
|
|
241
|
+
}
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
process.stdout.write(`${commits.length} commit(s) valid\n`);
|
package/src/doctor.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { inspectPortableState } from './portable.mjs';
|
|
20
20
|
import { inspectSyncOutbox, readLocalSyncState } from './sync-outbox.mjs';
|
|
21
21
|
import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
|
|
22
|
+
import { inspectGitCommitHooks } from './git-commit-hooks.mjs';
|
|
22
23
|
|
|
23
24
|
const healthStatusLabel = (status) => ({
|
|
24
25
|
healthy: 'saudável', warning: 'atenção', degraded: 'degradada', blocked: 'bloqueada', legacy: 'legado',
|
|
@@ -182,6 +183,11 @@ export function runDoctor(argv) {
|
|
|
182
183
|
process.stdout.write(` → ${issue.slug}: ${issue.errorCode} — ${issue.repair}\n`);
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
const commitHooks = inspectGitCommitHooks({ projectRoot });
|
|
187
|
+
process.stdout.write(`\n[commit-hooks] ${commitHooks.status}\n`);
|
|
188
|
+
for (const issue of commitHooks.issues) process.stdout.write(` ! ${issue}\n`);
|
|
189
|
+
if (commitHooks.repair) process.stdout.write(` → ${commitHooks.repair}\n`);
|
|
190
|
+
|
|
185
191
|
const activeContexts = inspectActiveContextHealth({ vaultBase, projectRoot });
|
|
186
192
|
process.stdout.write(`\n${renderActiveContextHealthLines(activeContexts).join('\n')}\n`);
|
|
187
193
|
|
|
@@ -257,6 +263,7 @@ export function runDoctor(argv) {
|
|
|
257
263
|
|| repairable.length
|
|
258
264
|
|| warnings.length
|
|
259
265
|
|| worktrees.issues.length
|
|
266
|
+
|| (commitHooks.configured && commitHooks.status !== 'healthy')
|
|
260
267
|
|| activeContexts.issues.length
|
|
261
268
|
|| ['diverged', 'invalid'].includes(portable.status)
|
|
262
269
|
|| sync.status === 'corrupt'
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const GIT_COMMIT_HOOKS = ['prepare-commit-msg', 'commit-msg'];
|
|
7
|
+
export const GIT_COMMIT_HOOKS_PATH = '.githooks';
|
|
8
|
+
|
|
9
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const packagedHooks = join(packageRoot, '.githooks');
|
|
11
|
+
|
|
12
|
+
function git(projectRoot, args, { allowFailure = false } = {}) {
|
|
13
|
+
const result = spawnSync('git', args, {
|
|
14
|
+
cwd: projectRoot,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
});
|
|
18
|
+
if (!allowFailure && result.status !== 0) {
|
|
19
|
+
const error = new Error((result.stderr || `git ${args.join(' ')} failed`).trim());
|
|
20
|
+
error.code = 'WENDKEEP_COMMIT_GIT_FAILED';
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hookState(projectRoot, name) {
|
|
27
|
+
const source = join(packagedHooks, name);
|
|
28
|
+
const target = join(projectRoot, GIT_COMMIT_HOOKS_PATH, name);
|
|
29
|
+
if (!existsSync(target)) return { name, source, target, state: 'missing' };
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
source,
|
|
33
|
+
target,
|
|
34
|
+
state: readFileSync(target).equals(readFileSync(source)) ? 'current' : 'drift',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inspectGitCommitHooks({ projectRoot = process.cwd() } = {}) {
|
|
39
|
+
const root = resolve(projectRoot);
|
|
40
|
+
const repository = git(root, ['rev-parse', '--show-toplevel'], { allowFailure: true });
|
|
41
|
+
if (repository.status !== 0) {
|
|
42
|
+
return { status: 'unavailable', configured: false, issues: ['not a Git repository'], repair: '' };
|
|
43
|
+
}
|
|
44
|
+
const configured = git(root, ['config', '--local', '--get', 'core.hooksPath'], { allowFailure: true });
|
|
45
|
+
const configuredPath = configured.status === 0 ? configured.stdout.trim().replaceAll('\\', '/') : '';
|
|
46
|
+
if (!configuredPath) {
|
|
47
|
+
return { status: 'disabled', configured: false, issues: [], repair: 'wendkeep init --git-commit-hooks --yes' };
|
|
48
|
+
}
|
|
49
|
+
if (!['.githooks', './.githooks'].includes(configuredPath)) {
|
|
50
|
+
return {
|
|
51
|
+
status: 'drift',
|
|
52
|
+
configured: true,
|
|
53
|
+
configuredPath,
|
|
54
|
+
issues: [`core.hooksPath points to ${configuredPath}, not .githooks`],
|
|
55
|
+
repair: 'wendkeep init --git-commit-hooks --force --yes',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const states = GIT_COMMIT_HOOKS.map((name) => hookState(root, name));
|
|
59
|
+
const issues = states.filter((item) => item.state !== 'current').map((item) => (
|
|
60
|
+
item.state === 'missing'
|
|
61
|
+
? `${item.name}: missing`
|
|
62
|
+
: `${item.name}: content differs from the installed WendKeep version`
|
|
63
|
+
));
|
|
64
|
+
return {
|
|
65
|
+
status: issues.length ? (states.some((item) => item.state === 'missing') ? 'missing' : 'drift') : 'healthy',
|
|
66
|
+
configured: true,
|
|
67
|
+
configuredPath,
|
|
68
|
+
issues,
|
|
69
|
+
repair: issues.length ? 'wendkeep init --git-commit-hooks --force --yes' : '',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function installGitCommitHooks({ projectRoot = process.cwd(), force = false } = {}) {
|
|
74
|
+
const root = resolve(projectRoot);
|
|
75
|
+
git(root, ['rev-parse', '--show-toplevel']);
|
|
76
|
+
const configured = git(root, ['config', '--local', '--get', 'core.hooksPath'], { allowFailure: true });
|
|
77
|
+
const configuredPath = configured.status === 0 ? configured.stdout.trim().replaceAll('\\', '/') : '';
|
|
78
|
+
if (configuredPath && !['.githooks', './.githooks'].includes(configuredPath) && !force) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'conflict',
|
|
81
|
+
conflicts: ['core.hooksPath'],
|
|
82
|
+
configuredPath,
|
|
83
|
+
repair: 'wendkeep init --git-commit-hooks --force --yes',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const states = GIT_COMMIT_HOOKS.map((name) => hookState(root, name));
|
|
87
|
+
const conflicts = states.filter((item) => item.state === 'drift').map((item) => item.name);
|
|
88
|
+
if (conflicts.length && !force) {
|
|
89
|
+
return { status: 'conflict', conflicts, repair: 'wendkeep init --git-commit-hooks --force --yes' };
|
|
90
|
+
}
|
|
91
|
+
mkdirSync(join(root, GIT_COMMIT_HOOKS_PATH), { recursive: true });
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const item of states) {
|
|
94
|
+
if (item.state === 'current') continue;
|
|
95
|
+
if (item.state === 'drift' && force && !existsSync(`${item.target}.bak`)) {
|
|
96
|
+
copyFileSync(item.target, `${item.target}.bak`);
|
|
97
|
+
}
|
|
98
|
+
copyFileSync(item.source, item.target);
|
|
99
|
+
try { chmodSync(item.target, 0o755); } catch { /* Git for Windows uses its executable shim. */ }
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
if (configured.status !== 0 || configured.stdout.trim().replaceAll('\\', '/') !== GIT_COMMIT_HOOKS_PATH) {
|
|
103
|
+
git(root, ['config', '--local', 'core.hooksPath', GIT_COMMIT_HOOKS_PATH]);
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: changed ? 'installed' : 'unchanged',
|
|
108
|
+
conflicts: [],
|
|
109
|
+
hooks: GIT_COMMIT_HOOKS,
|
|
110
|
+
path: join(root, GIT_COMMIT_HOOKS_PATH),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { adoptSpecsState, ensureSpecsReadme, SPECS_STATE_FILE } from '../hooks/s
|
|
|
41
41
|
import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
|
|
42
42
|
import { seedMemoryV2 } from './memory.mjs';
|
|
43
43
|
import { installVscodeWorktreeTasks } from './worktree.mjs';
|
|
44
|
+
import { installGitCommitHooks } from './git-commit-hooks.mjs';
|
|
44
45
|
import {
|
|
45
46
|
DEFAULT_OPERATING_PROFILE,
|
|
46
47
|
normalizeOperatingProfile,
|
|
@@ -69,6 +70,7 @@ function parseArgs(argv) {
|
|
|
69
70
|
else if (a === '--no-companions') args.noCompanions = true;
|
|
70
71
|
else if (a === '--no-colors') args.noColors = true;
|
|
71
72
|
else if (a === '--vscode-worktree-tasks') args.vscodeWorktreeTasks = true;
|
|
73
|
+
else if (a === '--git-commit-hooks') args.gitCommitHooks = true;
|
|
72
74
|
else if (a === '--dotcontext-mcp') args.dotcontextMcp = argv[++i];
|
|
73
75
|
else if (a.startsWith('--dotcontext-mcp=')) args.dotcontextMcp = a.slice(17);
|
|
74
76
|
else if (a === '--dotcontext-hooks') args.dotcontextHooks = argv[++i];
|
|
@@ -639,6 +641,17 @@ export async function runInit(argv) {
|
|
|
639
641
|
}
|
|
640
642
|
log(M.codexTrust);
|
|
641
643
|
|
|
644
|
+
// Git commit policy is opt-in because this is the only init surface that writes
|
|
645
|
+
// repository-local Git configuration. Existing custom hooks are never overwritten silently.
|
|
646
|
+
if (args.gitCommitHooks) {
|
|
647
|
+
const hooks = installGitCommitHooks({ projectRoot: projectPath, force: args.force });
|
|
648
|
+
if (hooks.status === 'conflict') {
|
|
649
|
+
log(` [!] Git commit hooks preserved (${hooks.conflicts.join(', ')}); review and rerun ${hooks.repair}`);
|
|
650
|
+
} else {
|
|
651
|
+
log(` Git commit hooks: ${hooks.status} (${hooks.path})`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
642
655
|
// 3. .mcp.json --------------------------------------------------------------
|
|
643
656
|
// Written when the native WendKeep MCP is wanted OR a selected companion ships an MCP server.
|
|
644
657
|
const companionMcp = companionMcpPatch(companions, skipMcp);
|
package/src/observer-auth.mjs
CHANGED
|
@@ -8,3 +8,11 @@ export function observerAuthHeaders(token, headers = {}) {
|
|
|
8
8
|
? { ...headers, authorization: `Bearer ${resolved}` }
|
|
9
9
|
: { ...headers };
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
registerObserverToken,
|
|
14
|
+
resolveObserverPrincipal,
|
|
15
|
+
revokeObserverToken,
|
|
16
|
+
rotateObserverToken,
|
|
17
|
+
} from '../packages/observer/src/token-registry.mjs';
|
|
18
|
+
export { authorizeObserverPrincipal } from '../packages/observer/src/authz.mjs';
|